Every URL the player opens is a media-server stream or a local file, so mpv's bundled ytdl_hook has nothing to resolve. It still ran an on_load hook per open and, whenever an open failed, spawned yt-dlp with the full stream URL in its argv — access token included, readable through /proc on Linux. It also added ~700ms to every failed open and buried the real "[stream] Failed to open" line under three ytdl_hook errors. mpv decides whether to load the builtin script inside mpv_initialize, so this has to be an option set beforehand rather than a property set from Dart. Verified against mpv 0.41: --ytdl=yes logs "Loading lua script @ytdl_hook.lua", --ytdl=no never loads it. Apple is deliberately excluded: the bundled libmpv is built without Lua, so the option does not exist there and setting it would only print an mpv error on every player init.
915 lines
34 KiB
C++
915 lines
34 KiB
C++
#include "mpv_player.h"
|
|
|
|
#include <commctrl.h>
|
|
#include <windowsx.h>
|
|
|
|
#include <unordered_map>
|
|
|
|
#include "sanitize_utf8.h"
|
|
|
|
namespace mpv {
|
|
|
|
struct InnerWindowSubclassState {
|
|
HWND hwnd = nullptr;
|
|
std::atomic<HWND> forward_target{nullptr};
|
|
std::atomic<bool> active{false};
|
|
std::atomic<uint64_t> forwarded_pointer_token{0};
|
|
std::atomic<LPARAM> forwarded_pointer_position{0};
|
|
std::mutex forwarded_pointer_mutex;
|
|
UINT_PTR subclass_id = 0;
|
|
// Guarded by g_inner_subclasses_mutex.
|
|
bool installed = false;
|
|
// While true, the window thread may still remove this generation, so a
|
|
// replacement must not adopt it.
|
|
bool removal_pending = false;
|
|
};
|
|
|
|
namespace {
|
|
|
|
// Adapts the shared, bounded mpv_node walk onto Flutter's encodable values.
|
|
struct EncodableNodeBuilder {
|
|
using Value = flutter::EncodableValue;
|
|
using ListBuilder = flutter::EncodableList;
|
|
using MapBuilder = flutter::EncodableMap;
|
|
|
|
static Value Null() { return flutter::EncodableValue(); }
|
|
static Value Boolean(bool value) { return flutter::EncodableValue(value); }
|
|
static Value Int(int64_t value) { return flutter::EncodableValue(value); }
|
|
static Value Double(double value) { return flutter::EncodableValue(value); }
|
|
static Value String(const char* value, size_t length) { return flutter::EncodableValue(SanitizeUtf8(value, length)); }
|
|
|
|
static ListBuilder NewList() { return flutter::EncodableList(); }
|
|
static void Append(ListBuilder& list, Value value) { list.push_back(std::move(value)); }
|
|
static Value FinishList(ListBuilder list) { return flutter::EncodableValue(std::move(list)); }
|
|
|
|
static MapBuilder NewMap() { return flutter::EncodableMap(); }
|
|
static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) {
|
|
map[flutter::EncodableValue(SanitizeUtf8(key, key_length))] = std::move(value);
|
|
}
|
|
static Value FinishMap(MapBuilder map) { return flutter::EncodableValue(std::move(map)); }
|
|
static void AbandonMap(MapBuilder&) {}
|
|
};
|
|
|
|
flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) {
|
|
return plezy::mpv_common::ConvertNode<EncodableNodeBuilder>(node);
|
|
}
|
|
|
|
// Input ownership for the DComp video child.
|
|
//
|
|
// The video host window is created disabled (WS_DISABLED). A disabled window
|
|
// and — implicitly — its children are skipped when the system picks the window
|
|
// that owns a contact, and the input is handed to the parent instead, so mouse,
|
|
// touch, and pen over the video land on the Flutter view itself, on the
|
|
// platform thread, with their real device kind. That is the only hit-test
|
|
// opt-out that works across threads: WS_EX_TRANSPARENT and an HTTRANSPARENT
|
|
// WM_NCHITTEST reply are both documented as same-thread-only, and mpv's inner
|
|
// window lives on mpv's own thread.
|
|
//
|
|
// The subclass below is the fallback for input that still reaches mpv's window.
|
|
// It uses the common-controls subclass chain with per-window reference data.
|
|
// Mouse messages can be posted straight through, but a WM_POINTER message
|
|
// cannot be forwarded as-is: Flutter resolves it with GetPointerInfo, which
|
|
// only answers for a pointer message the calling thread retrieved itself, and
|
|
// silently drops the event when that lookup fails. The primary contact is
|
|
// therefore translated into mouse input.
|
|
constexpr bool IsForwardedPointerMessage(UINT message) {
|
|
switch (message) {
|
|
case WM_POINTERDOWN:
|
|
case WM_POINTERUPDATE:
|
|
case WM_POINTERUP:
|
|
case WM_POINTERLEAVE:
|
|
case WM_POINTERCAPTURECHANGED:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static_assert(IsForwardedPointerMessage(WM_POINTERDOWN));
|
|
static_assert(IsForwardedPointerMessage(WM_POINTERUPDATE));
|
|
static_assert(IsForwardedPointerMessage(WM_POINTERUP));
|
|
static_assert(IsForwardedPointerMessage(WM_POINTERLEAVE));
|
|
static_assert(IsForwardedPointerMessage(WM_POINTERCAPTURECHANGED));
|
|
static_assert(!IsForwardedPointerMessage(WM_MOUSEMOVE));
|
|
|
|
uint64_t PointerToken(WPARAM wparam) { return static_cast<uint64_t>(GET_POINTERID_WPARAM(wparam)) + 1; }
|
|
|
|
LPARAM PointerPositionInView(HWND view, LPARAM lparam) {
|
|
POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
|
|
::ScreenToClient(view, &point);
|
|
return MAKELPARAM(point.x, point.y);
|
|
}
|
|
|
|
void ReleaseForwardedPointer(InnerWindowSubclassState& state, HWND view) {
|
|
std::lock_guard<std::mutex> lock(state.forwarded_pointer_mutex);
|
|
if (state.forwarded_pointer_token.exchange(0, std::memory_order_acq_rel) == 0 || !view) {
|
|
return;
|
|
}
|
|
const LPARAM position = state.forwarded_pointer_position.load(std::memory_order_acquire);
|
|
::PostMessageW(view, WM_LBUTTONUP, 0, position);
|
|
}
|
|
|
|
bool ForwardPointerAsMouse(InnerWindowSubclassState& state, HWND view, UINT message, WPARAM wparam, LPARAM lparam) {
|
|
if (!IsForwardedPointerMessage(message)) return false;
|
|
|
|
std::lock_guard<std::mutex> lock(state.forwarded_pointer_mutex);
|
|
if (!state.active.load(std::memory_order_acquire) || state.forward_target.load(std::memory_order_acquire) != view) {
|
|
return true;
|
|
}
|
|
const uint64_t pointer_token = PointerToken(wparam);
|
|
if (message == WM_POINTERDOWN) {
|
|
if (!IS_POINTER_PRIMARY_WPARAM(wparam) || !IS_POINTER_FIRSTBUTTON_WPARAM(wparam)) {
|
|
return true;
|
|
}
|
|
|
|
uint64_t expected = 0;
|
|
if (!state.forwarded_pointer_token.compare_exchange_strong(expected, pointer_token)) {
|
|
return true;
|
|
}
|
|
|
|
const LPARAM position = PointerPositionInView(view, lparam);
|
|
state.forwarded_pointer_position.store(position, std::memory_order_release);
|
|
::PostMessageW(view, WM_LBUTTONDOWN, MK_LBUTTON, position);
|
|
return true;
|
|
}
|
|
|
|
if (state.forwarded_pointer_token.load(std::memory_order_acquire) != pointer_token) {
|
|
return true;
|
|
}
|
|
|
|
if (message == WM_POINTERUPDATE) {
|
|
const LPARAM position = PointerPositionInView(view, lparam);
|
|
state.forwarded_pointer_position.store(position, std::memory_order_release);
|
|
::PostMessageW(view, WM_MOUSEMOVE, MK_LBUTTON, position);
|
|
return true;
|
|
}
|
|
|
|
uint64_t expected = pointer_token;
|
|
if (!state.forwarded_pointer_token.compare_exchange_strong(expected, 0)) {
|
|
return true;
|
|
}
|
|
|
|
const LPARAM position = message == WM_POINTERCAPTURECHANGED
|
|
? state.forwarded_pointer_position.load(std::memory_order_acquire)
|
|
: PointerPositionInView(view, lparam);
|
|
state.forwarded_pointer_position.store(position, std::memory_order_release);
|
|
::PostMessageW(view, WM_LBUTTONUP, 0, position);
|
|
return true;
|
|
}
|
|
|
|
std::mutex g_inner_subclasses_mutex;
|
|
std::unordered_map<HWND, std::shared_ptr<InnerWindowSubclassState>> g_inner_subclasses;
|
|
std::atomic<uint64_t> g_next_inner_subclass_generation{1};
|
|
|
|
std::shared_ptr<InnerWindowSubclassState> FindInnerSubclassState(HWND hwnd, DWORD_PTR reference_data) {
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto it = g_inner_subclasses.find(hwnd);
|
|
if (it == g_inner_subclasses.end() || reinterpret_cast<DWORD_PTR>(it->second.get()) != reference_data) {
|
|
return nullptr;
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
LRESULT CALLBACK MpvInnerSubclassProc(
|
|
HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR subclass_id, DWORD_PTR reference_data) {
|
|
const auto state = FindInnerSubclassState(hwnd, reference_data);
|
|
if (!state) {
|
|
return ::DefSubclassProc(hwnd, message, wparam, lparam);
|
|
}
|
|
|
|
const bool active = state->active.load(std::memory_order_acquire);
|
|
HWND view = active ? state->forward_target.load(std::memory_order_acquire) : nullptr;
|
|
if (active && view && ForwardPointerAsMouse(*state, view, message, wparam, lparam)) {
|
|
return 0;
|
|
}
|
|
|
|
if (active && message >= WM_MOUSEFIRST && message <= WM_MOUSELAST) {
|
|
if (view) {
|
|
LPARAM forwarded = lparam;
|
|
if (message != WM_MOUSEWHEEL && message != WM_MOUSEHWHEEL) {
|
|
// Client coordinates: translate inner-window-space -> view-space.
|
|
// (Wheel messages carry screen coordinates; pass through unchanged.)
|
|
POINT pt = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
|
|
::MapWindowPoints(hwnd, view, &pt, 1);
|
|
forwarded = MAKELPARAM(pt.x, pt.y);
|
|
}
|
|
::PostMessageW(view, message, wparam, forwarded);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (message == WM_NCDESTROY) {
|
|
state->active.store(false, std::memory_order_release);
|
|
const HWND forward_target = state->forward_target.exchange(nullptr, std::memory_order_acq_rel);
|
|
ReleaseForwardedPointer(*state, forward_target);
|
|
::RemoveWindowSubclass(hwnd, MpvInnerSubclassProc, subclass_id);
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto it = g_inner_subclasses.find(hwnd);
|
|
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
|
g_inner_subclasses.erase(it);
|
|
}
|
|
}
|
|
return ::DefSubclassProc(hwnd, message, wparam, lparam);
|
|
}
|
|
|
|
constexpr UINT kSubclassOwnershipMessage = WM_APP + 0x0504;
|
|
|
|
enum class SubclassOwnershipActionPhase {
|
|
kPending,
|
|
kRunning,
|
|
kCompleted,
|
|
};
|
|
|
|
struct SubclassOwnershipAction {
|
|
std::shared_ptr<InnerWindowSubclassState> state;
|
|
bool install;
|
|
std::mutex mutex;
|
|
SubclassOwnershipActionPhase phase = SubclassOwnershipActionPhase::kPending;
|
|
bool cancelled = false;
|
|
bool success = false;
|
|
};
|
|
|
|
std::mutex g_subclass_actions_mutex;
|
|
std::unordered_map<UINT_PTR, std::shared_ptr<SubclassOwnershipAction>> g_subclass_actions;
|
|
std::atomic<UINT_PTR> g_next_subclass_action{1};
|
|
|
|
void ForgetInnerSubclassState(const std::shared_ptr<InnerWindowSubclassState>& state) {
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto it = g_inner_subclasses.find(state->hwnd);
|
|
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
|
g_inner_subclasses.erase(it);
|
|
}
|
|
}
|
|
|
|
bool ApplySubclassOwnershipAction(const SubclassOwnershipAction& action) {
|
|
if (action.install) {
|
|
return ::SetWindowSubclass(
|
|
action.state->hwnd, MpvInnerSubclassProc, action.state->subclass_id,
|
|
reinterpret_cast<DWORD_PTR>(action.state.get())) != FALSE;
|
|
}
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto it = g_inner_subclasses.find(action.state->hwnd);
|
|
if (it == g_inner_subclasses.end() || it->second.get() != action.state.get()) {
|
|
return false;
|
|
}
|
|
|
|
const bool removed =
|
|
::RemoveWindowSubclass(action.state->hwnd, MpvInnerSubclassProc, action.state->subclass_id) != FALSE;
|
|
if (removed) {
|
|
g_inner_subclasses.erase(it);
|
|
} else {
|
|
action.state->removal_pending = false;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
void ExecuteSubclassOwnershipAction(const std::shared_ptr<SubclassOwnershipAction>& action) {
|
|
{
|
|
std::lock_guard<std::mutex> lock(action->mutex);
|
|
if (action->phase != SubclassOwnershipActionPhase::kPending) return;
|
|
if (action->cancelled) {
|
|
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
|
if (action->install) {
|
|
ForgetInnerSubclassState(action->state);
|
|
}
|
|
return;
|
|
}
|
|
action->phase = SubclassOwnershipActionPhase::kRunning;
|
|
}
|
|
|
|
const bool applied = ApplySubclassOwnershipAction(*action);
|
|
bool cancelled_install = false;
|
|
{
|
|
std::lock_guard<std::mutex> lock(action->mutex);
|
|
cancelled_install = action->install && action->cancelled;
|
|
if (!cancelled_install) {
|
|
action->success = applied;
|
|
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
|
}
|
|
}
|
|
|
|
if (!cancelled_install) {
|
|
if (action->install && !applied) {
|
|
ForgetInnerSubclassState(action->state);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A timeout can race an action that the window thread has already begun.
|
|
// Remove a late install on that same thread before releasing the action's
|
|
// shared ownership of the reference data used by the subclass callback.
|
|
const bool detached = !applied || ::RemoveWindowSubclass(
|
|
action->state->hwnd, MpvInnerSubclassProc, action->state->subclass_id) != FALSE;
|
|
if (detached) {
|
|
ForgetInnerSubclassState(action->state);
|
|
}
|
|
std::lock_guard<std::mutex> lock(action->mutex);
|
|
action->success = false;
|
|
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
|
}
|
|
|
|
LRESULT CALLBACK SubclassOwnershipHook(int code, WPARAM wparam, LPARAM lparam) {
|
|
if (code >= 0) {
|
|
const auto* message = reinterpret_cast<const CWPSTRUCT*>(lparam);
|
|
if (message && message->message == kSubclassOwnershipMessage) {
|
|
std::shared_ptr<SubclassOwnershipAction> action;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
|
const auto it = g_subclass_actions.find(static_cast<UINT_PTR>(message->wParam));
|
|
if (it != g_subclass_actions.end() && it->second->state->hwnd == message->hwnd) {
|
|
action = it->second;
|
|
}
|
|
}
|
|
if (action) {
|
|
ExecuteSubclassOwnershipAction(action);
|
|
}
|
|
}
|
|
}
|
|
return ::CallNextHookEx(nullptr, code, wparam, lparam);
|
|
}
|
|
|
|
bool RunSubclassOwnershipActionOnWindowThread(const std::shared_ptr<SubclassOwnershipAction>& action) {
|
|
DWORD window_thread = ::GetWindowThreadProcessId(action->state->hwnd, nullptr);
|
|
if (!window_thread) return false;
|
|
if (window_thread == ::GetCurrentThreadId()) {
|
|
ExecuteSubclassOwnershipAction(action);
|
|
std::lock_guard<std::mutex> lock(action->mutex);
|
|
return action->success;
|
|
}
|
|
|
|
HHOOK hook = ::SetWindowsHookExW(WH_CALLWNDPROC, SubclassOwnershipHook, nullptr, window_thread);
|
|
if (!hook) return false;
|
|
|
|
const UINT_PTR action_id = g_next_subclass_action.fetch_add(1, std::memory_order_relaxed);
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
|
g_subclass_actions[action_id] = action;
|
|
}
|
|
|
|
DWORD_PTR message_result = 0;
|
|
::SendMessageTimeoutW(
|
|
action->state->hwnd, kSubclassOwnershipMessage, action_id, 0, SMTO_ABORTIFHUNG, 1000, &message_result);
|
|
|
|
bool success = false;
|
|
{
|
|
// Completion and cancellation use the same lock. If the callback won the
|
|
// race, its acknowledged result is authoritative. Otherwise it observes
|
|
// cancellation and cannot leave a late install referencing released data.
|
|
std::lock_guard<std::mutex> lock(action->mutex);
|
|
if (action->phase == SubclassOwnershipActionPhase::kCompleted) {
|
|
success = action->success;
|
|
} else {
|
|
action->cancelled = true;
|
|
}
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
|
const auto it = g_subclass_actions.find(action_id);
|
|
if (it != g_subclass_actions.end() && it->second == action) {
|
|
g_subclass_actions.erase(it);
|
|
}
|
|
}
|
|
::UnhookWindowsHookEx(hook);
|
|
return success;
|
|
}
|
|
|
|
std::shared_ptr<InnerWindowSubclassState> InstallMpvInnerSubclass(HWND inner, HWND forward_target) {
|
|
if (!inner || !forward_target) return nullptr;
|
|
|
|
std::shared_ptr<InnerWindowSubclassState> state;
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto existing = g_inner_subclasses.find(inner);
|
|
if (existing != g_inner_subclasses.end()) {
|
|
const auto& retained = existing->second;
|
|
if (!retained->installed || retained->active.load(std::memory_order_acquire) || retained->removal_pending) {
|
|
return nullptr;
|
|
}
|
|
|
|
// A timed-out detach that never reached the window thread leaves the
|
|
// helper-chain entry installed. Adopt that exact generation rather than
|
|
// stacking a duplicate subclass or retaining a permanently inert entry.
|
|
retained->forwarded_pointer_token.store(0, std::memory_order_release);
|
|
retained->forward_target.store(forward_target, std::memory_order_release);
|
|
retained->active.store(true, std::memory_order_release);
|
|
return retained;
|
|
}
|
|
state = std::make_shared<InnerWindowSubclassState>();
|
|
state->hwnd = inner;
|
|
state->forward_target.store(forward_target, std::memory_order_relaxed);
|
|
state->subclass_id = g_next_inner_subclass_generation.fetch_add(1, std::memory_order_relaxed);
|
|
|
|
// Publish the state before installing the helper-chain entry. The
|
|
// callback's reference data identifies this exact generation, so an old
|
|
// callback can never resolve a replacement generation that reuses HWND.
|
|
g_inner_subclasses[inner] = state;
|
|
}
|
|
|
|
auto action = std::make_shared<SubclassOwnershipAction>();
|
|
action->state = state;
|
|
action->install = true;
|
|
if (!RunSubclassOwnershipActionOnWindowThread(action)) {
|
|
bool action_never_started = false;
|
|
{
|
|
std::lock_guard<std::mutex> lock(action->mutex);
|
|
action_never_started = action->phase == SubclassOwnershipActionPhase::kPending;
|
|
}
|
|
if (action_never_started) {
|
|
ForgetInnerSubclassState(state);
|
|
}
|
|
return nullptr;
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto it = g_inner_subclasses.find(inner);
|
|
if (it == g_inner_subclasses.end() || it->second.get() != state.get()) {
|
|
return nullptr;
|
|
}
|
|
state->installed = true;
|
|
state->active.store(true, std::memory_order_release);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
void DetachMpvInnerSubclassState(const std::shared_ptr<InnerWindowSubclassState>& state) {
|
|
if (!state) return;
|
|
|
|
// Invalidate forwarding before removing the helper-chain entry. A callback
|
|
// already holding this generation can still call DefSubclassProc, but can
|
|
// no longer target a replacement Flutter/player generation.
|
|
|
|
HWND forward_target = nullptr;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto it = g_inner_subclasses.find(state->hwnd);
|
|
if (it == g_inner_subclasses.end() || it->second.get() != state.get()) {
|
|
return;
|
|
}
|
|
state->active.store(false, std::memory_order_release);
|
|
forward_target = state->forward_target.exchange(nullptr, std::memory_order_acq_rel);
|
|
state->removal_pending = true;
|
|
}
|
|
ReleaseForwardedPointer(*state, forward_target);
|
|
|
|
auto action = std::make_shared<SubclassOwnershipAction>();
|
|
action->state = state;
|
|
action->install = false;
|
|
const bool detached = RunSubclassOwnershipActionOnWindowThread(action);
|
|
if (!detached) {
|
|
bool removal_not_applied = false;
|
|
{
|
|
std::lock_guard<std::mutex> lock(action->mutex);
|
|
removal_not_applied = action->phase == SubclassOwnershipActionPhase::kPending ||
|
|
(action->phase == SubclassOwnershipActionPhase::kCompleted && !action->success);
|
|
}
|
|
if (removal_not_applied) {
|
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
|
const auto it = g_inner_subclasses.find(state->hwnd);
|
|
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
|
// RunSubclassOwnershipActionOnWindowThread has already unregistered
|
|
// the cancelled action and hook. The installed entry is now stable
|
|
// and can be reactivated by a replacement owner.
|
|
state->removal_pending = false;
|
|
}
|
|
}
|
|
}
|
|
if (!detached && !::IsWindow(state->hwnd)) {
|
|
// A destroyed HWND has already discarded its subclass chain, so no
|
|
// callback can retain the reference data even if dispatch was unavailable.
|
|
ForgetInnerSubclassState(state);
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
|
|
|
|
MpvPlayer::~MpvPlayer() { Dispose(); }
|
|
|
|
void MpvPlayer::EnsureMpvInnerSubclassed() {
|
|
if (!hwnd_ || !forward_target_view_) return;
|
|
|
|
HWND inner = ::FindWindowExW(hwnd_, nullptr, nullptr, nullptr);
|
|
if (!inner) return;
|
|
|
|
std::lock_guard<std::mutex> lock(inner_subclass_mutex_);
|
|
if (inner_subclass_ && inner_subclass_->hwnd == inner && inner_subclass_->active.load(std::memory_order_acquire)) {
|
|
inner_subclass_->forward_target.store(forward_target_view_, std::memory_order_release);
|
|
return;
|
|
}
|
|
|
|
DetachMpvInnerSubclassState(inner_subclass_);
|
|
inner_subclass_.reset();
|
|
inner_subclass_ = InstallMpvInnerSubclass(inner, forward_target_view_);
|
|
}
|
|
|
|
void MpvPlayer::DetachMpvInnerSubclass() {
|
|
std::lock_guard<std::mutex> lock(inner_subclass_mutex_);
|
|
DetachMpvInnerSubclassState(inner_subclass_);
|
|
inner_subclass_.reset();
|
|
}
|
|
|
|
bool MpvPlayer::Initialize(HWND view) {
|
|
if (mpv_) {
|
|
return true; // Already initialized.
|
|
}
|
|
|
|
// Create mpv instance.
|
|
mpv_ = mpv_create();
|
|
if (!mpv_) {
|
|
return false;
|
|
}
|
|
|
|
if (audio_only_) {
|
|
// Windowless music core: no HWND, no VO, no video decode. vid=no keeps
|
|
// embedded cover art from ever becoming a video track, and
|
|
// force-window/audio-display make sure mpv never opens a video output
|
|
// for it either.
|
|
mpv_set_option_string(mpv_, "vid", "no");
|
|
mpv_set_option_string(mpv_, "force-window", "no");
|
|
mpv_set_option_string(mpv_, "audio-display", "no");
|
|
mpv_set_option_string(mpv_, "gapless-audio", "weak");
|
|
} else {
|
|
// Create a child window for mpv to render into, parented to the Flutter
|
|
// |view|. The video child then sits in the view's own per-window layer
|
|
// stack, above the view's (never-painted) layer-1 content and below the
|
|
// engine's topmost DComp visual carrying the UI. WS_CLIPSIBLINGS keeps it
|
|
// from painting over neighboring view children.
|
|
//
|
|
// WS_DISABLED takes the host — and the inner window mpv creates inside it,
|
|
// which a disabled parent disables implicitly — out of input targeting, so
|
|
// mouse, touch, and pen over the video are delivered to the parent Flutter
|
|
// view instead of to mpv's thread. mpv never consumes input here anyway
|
|
// (input-vo-keyboard=no, and the forwarding subclass swallows the rest).
|
|
hwnd_ = ::CreateWindowExW(
|
|
WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle, 0, 0, 100, 100, view, nullptr,
|
|
GetModuleHandle(nullptr), nullptr);
|
|
if (!hwnd_) {
|
|
mpv_destroy(mpv_);
|
|
mpv_ = nullptr;
|
|
return false;
|
|
}
|
|
forward_target_view_ = view;
|
|
|
|
// Set the wid option to embed mpv in our window.
|
|
int64_t wid = reinterpret_cast<int64_t>(hwnd_);
|
|
mpv_set_option(mpv_, "wid", MPV_FORMAT_INT64, &wid);
|
|
|
|
mpv_set_option_string(mpv_, "vo", "gpu-next");
|
|
mpv_set_option_string(mpv_, "gpu-api", "auto");
|
|
// hwdec is set from Flutter via setProperty based on user preference
|
|
}
|
|
|
|
// Configure mpv for embedded playback.
|
|
mpv_set_option_string(mpv_, "keep-open", "yes");
|
|
mpv_set_option_string(mpv_, "idle", "yes");
|
|
mpv_set_option_string(mpv_, "input-default-bindings", "no");
|
|
mpv_set_option_string(mpv_, "input-vo-keyboard", "no");
|
|
// Hardware media keys are owned by the SMTC integration (os_media_controls);
|
|
// mpv's default handling would double-handle Play/Pause.
|
|
mpv_set_option_string(mpv_, "input-media-keys", "no");
|
|
mpv_set_option_string(mpv_, "osc", "no");
|
|
// Never resolve URLs through mpv's bundled ytdl_hook: Plezy only ever opens
|
|
// media-server streams and local files, the hook adds a per-open on_load
|
|
// round trip, and on a failed open it spawns yt-dlp with the access token in
|
|
// its argv. mpv decides whether to load the builtin script during
|
|
// mpv_initialize, so this must be an option, not a Dart setProperty.
|
|
mpv_set_option_string(mpv_, "ytdl", "no");
|
|
|
|
if (!audio_only_) {
|
|
// Let mpv use display/context detection instead of forcing HDR signaling.
|
|
mpv_set_option_string(mpv_, "target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(hdr_enabled_));
|
|
|
|
// Fallback tone mapping when display doesn't support HDR
|
|
mpv_set_option_string(mpv_, "tone-mapping", "auto");
|
|
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
|
|
}
|
|
|
|
// When WASAPI becomes unavailable (sleep, device unplug), fall back to null
|
|
// audio output instead of permanently dropping the audio track. Recovery is
|
|
// handled by MaybeRunAudioRecovery in the event loop.
|
|
mpv_set_option_string(mpv_, "audio-fallback-to-null", "yes");
|
|
|
|
// Default to warn-level logging; Dart side can raise to "v" if debug logging is enabled.
|
|
mpv_request_log_messages(mpv_, "warn");
|
|
|
|
// Initialize mpv.
|
|
int err = mpv_initialize(mpv_);
|
|
if (err < 0) {
|
|
if (hwnd_) {
|
|
DetachMpvInnerSubclass();
|
|
forward_target_view_ = nullptr;
|
|
::DestroyWindow(hwnd_);
|
|
hwnd_ = nullptr;
|
|
}
|
|
mpv_destroy(mpv_);
|
|
mpv_ = nullptr;
|
|
return false;
|
|
}
|
|
|
|
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
|
|
// Native observation so audio recovery doesn't depend on the Dart side
|
|
// choosing to observe the device list.
|
|
mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE);
|
|
|
|
// Start event loop.
|
|
StartEventLoop();
|
|
|
|
return true;
|
|
}
|
|
|
|
void MpvPlayer::Dispose() {
|
|
StopEventLoop();
|
|
|
|
auto cancelled = pending_requests_.CancelAll();
|
|
for (auto& callback : cancelled.status) {
|
|
callback(MPV_ERROR_UNINITIALIZED);
|
|
}
|
|
for (auto& callback : cancelled.properties) {
|
|
callback(-1, "");
|
|
}
|
|
|
|
// Detach the native handle before releasing platform-owned state. mpv can
|
|
// block in demuxer/network teardown, so only the detached handle crosses to
|
|
// the worker; it must not retain this player, callbacks, or HWNDs.
|
|
auto* handle = mpv_;
|
|
mpv_ = nullptr;
|
|
|
|
// The input subclass must stop referencing this player generation before
|
|
// either the host HWND or the player object can be destroyed.
|
|
DetachMpvInnerSubclass();
|
|
forward_target_view_ = nullptr;
|
|
|
|
if (hwnd_) {
|
|
::ShowWindow(hwnd_, SW_HIDE);
|
|
::DestroyWindow(hwnd_);
|
|
hwnd_ = nullptr;
|
|
}
|
|
|
|
if (handle) {
|
|
std::thread([handle]() { mpv_terminate_destroy(handle); }).detach();
|
|
}
|
|
|
|
observed_properties_.Clear();
|
|
}
|
|
|
|
void MpvPlayer::Command(const std::vector<std::string>& args) { CommandAsync(args, nullptr); }
|
|
|
|
void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallback callback) {
|
|
if (!mpv_) {
|
|
if (callback) callback(0);
|
|
return;
|
|
}
|
|
|
|
plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback));
|
|
}
|
|
|
|
void MpvPlayer::SetProperty(const std::string& name, const std::string& value) {
|
|
SetPropertyAsync(name, value, nullptr);
|
|
}
|
|
|
|
void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& value, StatusCallback callback) {
|
|
if (!mpv_) {
|
|
if (callback) callback(MPV_ERROR_UNINITIALIZED);
|
|
return;
|
|
}
|
|
|
|
// Handle custom HDR toggle property (same pattern as iOS/macOS)
|
|
if (name == "hdr-enabled") {
|
|
SetHDREnabled(plezy::mpv_common::ParseEnabledFlag(value), std::move(callback));
|
|
return;
|
|
}
|
|
|
|
plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback));
|
|
}
|
|
|
|
void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) {
|
|
if (!mpv_) {
|
|
if (callback) callback(-1, "");
|
|
return;
|
|
}
|
|
|
|
plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback));
|
|
}
|
|
|
|
void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) {
|
|
if (!mpv_) return;
|
|
|
|
const auto request = observed_properties_.Register(name, format, id);
|
|
if (!request.added) return;
|
|
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
|
|
}
|
|
|
|
void MpvPlayer::SetRect(RECT rect, double device_pixel_ratio) {
|
|
if (!hwnd_) {
|
|
return;
|
|
}
|
|
|
|
// The video window is a child of the Flutter view; the Dart rect is already
|
|
// in view physical pixels, which is exactly the child coordinate space. No
|
|
// screen mapping, no padding.
|
|
::SetWindowPos(hwnd_, HWND_TOP, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, SWP_NOACTIVATE);
|
|
|
|
// mpv creates its inner window lazily on its own thread; subclass it (and
|
|
// re-subclass if mpv ever recreates it) so mouse and pointer input over the
|
|
// video is forwarded to the Flutter view.
|
|
EnsureMpvInnerSubclassed();
|
|
}
|
|
|
|
void MpvPlayer::SetVisible(bool visible) {
|
|
if (hwnd_) {
|
|
::ShowWindow(hwnd_, visible ? SW_SHOW : SW_HIDE);
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::SetLogLevel(const std::string& level) {
|
|
if (!mpv_) return;
|
|
mpv_request_log_messages(mpv_, level.c_str());
|
|
}
|
|
|
|
void MpvPlayer::SetEventCallback(EventCallback callback) {
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
event_callback_ = std::move(callback);
|
|
}
|
|
|
|
void MpvPlayer::NotifyPowerSuspend() { LogRecovery("system suspending"); }
|
|
|
|
void MpvPlayer::NotifyPowerResume() { audio_recovery_.RequestResume(); }
|
|
|
|
void MpvPlayer::LogRecovery(const std::string& text) {
|
|
char log_msg[512];
|
|
snprintf(log_msg, sizeof(log_msg), "MPV [warn] audio-recovery: %s", text.c_str());
|
|
OutputDebugStringA(log_msg);
|
|
|
|
// Emitted as a synthetic log-message event so it reaches the app logs
|
|
// regardless of the mpv log level.
|
|
flutter::EncodableMap data;
|
|
data[flutter::EncodableValue("prefix")] = flutter::EncodableValue("audio-recovery");
|
|
data[flutter::EncodableValue("level")] = flutter::EncodableValue("warn");
|
|
data[flutter::EncodableValue("text")] = flutter::EncodableValue(text);
|
|
SendEvent("log-message", data);
|
|
}
|
|
|
|
void MpvPlayer::TryAudioReload(const char* reason, int attempt, uint64_t request_generation) {
|
|
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
|
const std::string reason_copy = reason;
|
|
CommandAsync({"ao-reload"}, [this, reason_copy, attempt, request_generation](int error) {
|
|
audio_recovery_.CompleteReload(request_generation);
|
|
LogRecovery(
|
|
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
|
|
", error=" + std::to_string(error) + ")");
|
|
});
|
|
}
|
|
|
|
void MpvPlayer::MaybeRunAudioRecovery() {
|
|
const auto action = audio_recovery_.NextReload(plezy::mpv_common::AudioRecoveryState::Clock::now());
|
|
if (action.reason == plezy::mpv_common::AudioReloadReason::kNone) {
|
|
return;
|
|
}
|
|
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
|
|
TryAudioReload(reason, action.attempt, action.request_generation);
|
|
if (action.exhausted) {
|
|
LogRecovery("audio recovery budget exhausted; waiting for device list change");
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::StartEventLoop() {
|
|
running_ = true;
|
|
event_thread_ = std::thread(&MpvPlayer::EventLoop, this);
|
|
}
|
|
|
|
void MpvPlayer::StopEventLoop() {
|
|
running_ = false;
|
|
if (event_thread_.joinable()) {
|
|
// Wake up the event loop.
|
|
if (mpv_) {
|
|
mpv_wakeup(mpv_);
|
|
}
|
|
event_thread_.join();
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::EventLoop() {
|
|
while (running_) {
|
|
mpv_event* event = mpv_wait_event(mpv_, 0.1);
|
|
if (event->event_id == MPV_EVENT_SHUTDOWN) {
|
|
break;
|
|
}
|
|
if (event->event_id != MPV_EVENT_NONE) {
|
|
HandleMpvEvent(event);
|
|
}
|
|
// Runs on every iteration including wait timeouts: this ~100ms tick is
|
|
// the clock that drives scheduled audio reload attempts.
|
|
MaybeRunAudioRecovery();
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|
if (plezy::mpv_common::DispatchReplyEvent(
|
|
pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) {
|
|
return;
|
|
}
|
|
|
|
switch (event->event_id) {
|
|
case MPV_EVENT_LOG_MESSAGE: {
|
|
auto* msg = static_cast<mpv_event_log_message*>(event->data);
|
|
char log_msg[512];
|
|
snprintf(log_msg, sizeof(log_msg), "MPV [%s] %s: %s", msg->level, msg->prefix, msg->text);
|
|
OutputDebugStringA(log_msg);
|
|
|
|
flutter::EncodableMap data;
|
|
data[flutter::EncodableValue("prefix")] = flutter::EncodableValue(SanitizeUtf8(msg->prefix));
|
|
data[flutter::EncodableValue("level")] = flutter::EncodableValue(SanitizeUtf8(msg->level));
|
|
data[flutter::EncodableValue("text")] = flutter::EncodableValue(SanitizeUtf8(msg->text));
|
|
SendEvent("log-message", data);
|
|
break;
|
|
}
|
|
case MPV_EVENT_PROPERTY_CHANGE: {
|
|
auto* prop = static_cast<mpv_event_property*>(event->data);
|
|
mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop);
|
|
|
|
// The 100ms mpv_wait_event tick already polls for scheduled reloads.
|
|
const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop);
|
|
if (notice.message) LogRecovery(notice.message);
|
|
|
|
SendPropertyChange(prop->name, &node);
|
|
break;
|
|
}
|
|
case MPV_EVENT_END_FILE: {
|
|
audio_recovery_.SetFileLoaded(false);
|
|
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
|
flutter::EncodableMap data;
|
|
data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast<int>(end->reason));
|
|
if (end->reason == MPV_END_FILE_REASON_ERROR) {
|
|
data[flutter::EncodableValue("error")] = flutter::EncodableValue(static_cast<int>(end->error));
|
|
data[flutter::EncodableValue("message")] = flutter::EncodableValue(SanitizeUtf8(mpv_error_string(end->error)));
|
|
}
|
|
SendEvent("end-file", data);
|
|
break;
|
|
}
|
|
case MPV_EVENT_START_FILE: {
|
|
SendEvent("start-file");
|
|
break;
|
|
}
|
|
case MPV_EVENT_FILE_LOADED: {
|
|
audio_recovery_.SetFileLoaded(true);
|
|
SendEvent("file-loaded");
|
|
break;
|
|
}
|
|
case MPV_EVENT_PLAYBACK_RESTART: {
|
|
// mpv's inner window exists by now (vo is configured); make sure the
|
|
// DComp-mode input forwarding subclass is installed. SetRect alone can
|
|
// miss it: the rect often settles before mpv creates the window.
|
|
EnsureMpvInnerSubclassed();
|
|
SendEvent("playback-restart");
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
|
|
if (!name) return;
|
|
|
|
int id = 0;
|
|
if (!observed_properties_.LookupId(name, &id)) return;
|
|
|
|
// mpv owns event node storage; copy the full tree before the callback can
|
|
// queue it beyond the current mpv_wait_event result's lifetime.
|
|
flutter::EncodableList list;
|
|
list.push_back(flutter::EncodableValue(id));
|
|
list.push_back(NodeToEncodableValue(data));
|
|
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
if (event_callback_) {
|
|
event_callback_(flutter::EncodableValue(list));
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::SendEvent(const std::string& name, const flutter::EncodableMap& data) {
|
|
flutter::EncodableMap event;
|
|
event[flutter::EncodableValue("type")] = flutter::EncodableValue("event");
|
|
event[flutter::EncodableValue("name")] = flutter::EncodableValue(name);
|
|
if (!data.empty()) {
|
|
event[flutter::EncodableValue("data")] = flutter::EncodableValue(data);
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
if (event_callback_) {
|
|
event_callback_(flutter::EncodableValue(event));
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
|
|
hdr_enabled_ = enabled;
|
|
if (!mpv_) {
|
|
if (callback) callback(0);
|
|
return;
|
|
}
|
|
SetPropertyAsync("target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled), std::move(callback));
|
|
}
|
|
|
|
} // namespace mpv
|