feat(music): mpv audio playback engine with gapless queue service

Audio-only mpv core on every platform (dedicated
com.plezy/mpv_audio_player channels): parameterized android/windows/
linux mpv plugins and a new apple MpvAudioPlayerCore, all skipping
video/window paths (vid=no, audio-display=no, gapless-audio=weak).
MusicPlaybackService drives an in-memory queue with shuffle/repeat,
file-loaded-event gapless arming (property edges coalesce and the
android bridge drops them), per-track progress reporting, OS media
controls, audio focus, sleep timer, and error auto-skip.
PlaybackCoordinator enforces one live native player: starting video
disposes the audio core first.
This commit is contained in:
edde746
2026-07-05 19:26:28 +02:00
parent 05a631415e
commit 422db75b5b
41 changed files with 3353 additions and 269 deletions
+3 -1
View File
@@ -98,9 +98,11 @@ bool FlutterWindow::OnCreate() {
}
RegisterPlugins(flutter_controller_->engine());
// Register mpv player plugin.
// Register mpv player plugins (video + dedicated audio-only music core).
OutputDebugStringA("FlutterWindow: About to register MpvPlayerPlugin\n");
MpvPlayerPluginRegisterWithRegistrar(flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin"));
MpvAudioPlayerPluginRegisterWithRegistrar(
flutter_controller_->engine()->GetRegistrarForPlugin("MpvAudioPlayerPlugin"));
OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n");
RegisterWindowChannel();
+56 -36
View File
@@ -73,7 +73,7 @@ void EnsureMpvInnerSubclassed(HWND host) {
} // namespace
MpvPlayer::MpvPlayer() {}
MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
MpvPlayer::~MpvPlayer() { Dispose(); }
@@ -88,31 +88,43 @@ bool MpvPlayer::Initialize(HWND view) {
return false;
}
// 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. Mouse input over the video
// is delivered to mpv's own inner window (on mpv's thread); the subclass
// installed in EnsureMpvInnerSubclassed forwards it back to the view.
hwnd_ = ::CreateWindowExW(
WS_EX_NOPARENTNOTIFY, L"STATIC", L"", WS_CHILD | WS_CLIPSIBLINGS, 0, 0, 100, 100, view, nullptr,
GetModuleHandle(nullptr), nullptr);
if (!hwnd_) {
mpv_destroy(mpv_);
mpv_ = nullptr;
return false;
}
g_forward_target_view = view;
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. Mouse input over the video
// is delivered to mpv's own inner window (on mpv's thread); the subclass
// installed in EnsureMpvInnerSubclassed forwards it back to the view.
hwnd_ = ::CreateWindowExW(
WS_EX_NOPARENTNOTIFY, L"STATIC", L"", WS_CHILD | WS_CLIPSIBLINGS, 0, 0, 100, 100, view, nullptr,
GetModuleHandle(nullptr), nullptr);
if (!hwnd_) {
mpv_destroy(mpv_);
mpv_ = nullptr;
return false;
}
g_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);
// 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_, "vo", "gpu-next");
mpv_set_option_string(mpv_, "gpu-api", "auto");
// hwdec is set from Flutter via setProperty based on user preference
mpv_set_option_string(mpv_, "keep-open", "yes");
mpv_set_option_string(mpv_, "idle", "yes");
mpv_set_option_string(mpv_, "input-default-bindings", "no");
@@ -122,12 +134,14 @@ bool MpvPlayer::Initialize(HWND view) {
mpv_set_option_string(mpv_, "input-media-keys", "no");
mpv_set_option_string(mpv_, "osc", "no");
// Let mpv use display/context detection instead of forcing HDR signaling.
mpv_set_option_string(mpv_, "target-colorspace-hint", "auto");
if (!audio_only_) {
// Let mpv use display/context detection instead of forcing HDR signaling.
mpv_set_option_string(mpv_, "target-colorspace-hint", "auto");
// 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");
// 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
@@ -140,15 +154,19 @@ bool MpvPlayer::Initialize(HWND view) {
// Initialize mpv.
int err = mpv_initialize(mpv_);
if (err < 0) {
::DestroyWindow(hwnd_);
hwnd_ = nullptr;
if (hwnd_) {
::DestroyWindow(hwnd_);
hwnd_ = nullptr;
}
mpv_destroy(mpv_);
mpv_ = nullptr;
return false;
}
// Observe video-params/sig-peak for HDR detection
mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE);
// Observe video-params/sig-peak for HDR detection (video core only).
if (!audio_only_) {
mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE);
}
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.
@@ -192,11 +210,13 @@ void MpvPlayer::Dispose() {
if (hwnd_) {
::DestroyWindow(hwnd_);
hwnd_ = nullptr;
}
// The subclassed inner window died with hwnd_; clear the forwarding state.
g_mpv_inner_hwnd = nullptr;
g_mpv_inner_original_proc = nullptr;
// The subclassed inner window died with hwnd_; clear the forwarding
// state. Only the owner of the window may do this: the audio-only core
// (which never has an hwnd_) must not wipe the video instance's state.
g_mpv_inner_hwnd = nullptr;
g_mpv_inner_original_proc = nullptr;
}
observed_properties_.clear();
}
+6 -2
View File
@@ -23,13 +23,16 @@ class MpvPlayer {
public:
using EventCallback = std::function<void(const flutter::EncodableValue&)>;
MpvPlayer();
// |audio_only| runs mpv as a windowless music core: no child HWND, no VO,
// video decode disabled entirely (vid=no).
explicit MpvPlayer(bool audio_only = false);
~MpvPlayer();
// Initializes mpv and creates the video window as a child of the Flutter
// |view| window. The flutter-plezy engine presents the UI on a topmost
// DirectComposition visual, so the video child composites beneath it in the
// same HWND.
// same HWND. In audio-only mode |view| is ignored (pass nullptr) and no
// window is created.
bool Initialize(HWND view);
// Disposes mpv and the video window.
@@ -97,6 +100,7 @@ class MpvPlayer {
uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback);
GetPropertyCallback TakeGetPropertyRequest(uint64_t request_id);
const bool audio_only_;
mpv_handle* mpv_ = nullptr;
HWND hwnd_ = nullptr;
+55 -25
View File
@@ -13,29 +13,41 @@ void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef regis
flutter::PluginRegistrarManager::GetInstance()->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
}
void MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar) {
mpv::MpvPlayerPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarManager::GetInstance()->GetRegistrar<flutter::PluginRegistrarWindows>(registrar),
"com.plezy/mpv_audio_player", /*audio_only=*/true);
}
namespace mpv {
namespace {
constexpr UINT kPlatformTaskMessage = WM_APP + 0x4D50;
}
constexpr UINT kAudioPlatformTaskMessage = WM_APP + 0x4D51;
} // namespace
void MpvPlayerPlugin::RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar) {
auto plugin = std::make_unique<MpvPlayerPlugin>(registrar);
void MpvPlayerPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar, const std::string& channel_name, bool audio_only) {
auto plugin = std::make_unique<MpvPlayerPlugin>(registrar, channel_name, audio_only);
registrar->AddPlugin(std::move(plugin));
}
MpvPlayerPlugin::MpvPlayerPlugin(flutter::PluginRegistrarWindows* registrar)
: registrar_(registrar), platform_thread_id_(::GetCurrentThreadId()) {
MpvPlayerPlugin::MpvPlayerPlugin(
flutter::PluginRegistrarWindows* registrar, const std::string& channel_name, bool audio_only)
: registrar_(registrar),
platform_thread_id_(::GetCurrentThreadId()),
audio_only_(audio_only),
platform_task_message_(audio_only ? kAudioPlatformTaskMessage : kPlatformTaskMessage) {
// Create method channel.
method_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
registrar->messenger(), "com.plezy/mpv_player", &flutter::StandardMethodCodec::GetInstance());
registrar->messenger(), channel_name, &flutter::StandardMethodCodec::GetInstance());
method_channel_->SetMethodCallHandler(
[this](const auto& call, auto result) { HandleMethodCall(call, std::move(result)); });
// Create event channel.
event_channel_ = std::make_unique<flutter::EventChannel<flutter::EncodableValue>>(
registrar->messenger(), "com.plezy/mpv_player/events", &flutter::StandardMethodCodec::GetInstance());
registrar->messenger(), channel_name + "/events", &flutter::StandardMethodCodec::GetInstance());
auto handler = std::make_unique<flutter::StreamHandlerFunctions<flutter::EncodableValue>>(
[this](
@@ -92,7 +104,7 @@ void MpvPlayerPlugin::PostToPlatformThread(std::function<void()> task) {
}
}
if (post_wakeup && !::PostMessage(flutter_window_, kPlatformTaskMessage, 0, 0)) {
if (post_wakeup && !::PostMessage(flutter_window_, platform_task_message_, 0, 0)) {
// Wakeup lost (e.g. message queue full during a log storm); let the next
// enqueue retry instead of stranding the queue.
std::lock_guard<std::mutex> lock(platform_tasks_mutex_);
@@ -133,7 +145,7 @@ void MpvPlayerPlugin::HandleMethodCall(
// topmost DComp visual — there is no separate container window to manage.
proc_id_ =
registrar_->RegisterTopLevelWindowProcDelegate([this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == kPlatformTaskMessage) {
if (message == platform_task_message_) {
DrainPlatformTasks();
return std::optional<HRESULT>(0);
}
@@ -156,18 +168,21 @@ void MpvPlayerPlugin::HandleMethodCall(
// and below the view's topmost DComp visual carrying the UI (layer 4). As
// a *sibling* of the view, either the view's never-painted white content
// covers the video or the video covers the UI — the in-subtree placement
// is the only ordering that yields white < video < UI.
HWND view = GetChildWindow();
// is the only ordering that yields white < video < UI. The audio-only
// core is windowless, so it gets no view at all.
HWND view = audio_only_ ? nullptr : GetChildWindow();
player_ = std::make_unique<MpvPlayer>();
player_ = std::make_unique<MpvPlayer>(audio_only_);
bool success = player_->Initialize(view);
if (success) {
// Set up event callback.
player_->SetEventCallback([this](const flutter::EncodableValue& event) { SendEvent(event); });
// Start hidden.
player_->SetVisible(false);
if (!audio_only_) {
// Start hidden.
player_->SetVisible(false);
}
result->Success(flutter::EncodableValue(true));
} else {
player_.reset(); // Clear the player so we don't have a half-initialized state
@@ -343,6 +358,12 @@ void MpvPlayerPlugin::HandleMethodCall(
std::get<int32_t>(id_it->second));
result->Success();
} else if (method == "setVisible") {
if (audio_only_) {
// Windowless core: nothing to show or hide, tolerate as a success no-op.
result->Success();
return;
}
const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -365,6 +386,12 @@ void MpvPlayerPlugin::HandleMethodCall(
result->Success();
} else if (method == "setVideoRect") {
if (audio_only_) {
// Windowless core: no rect to position, tolerate as a success no-op.
result->Success();
return;
}
const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -404,13 +431,16 @@ void MpvPlayerPlugin::HandleMethodCall(
player_->SetRect(rect, dpr);
}
result->Success();
} else if (audio_only_ && method == "updateFrame") {
// No frames to pump on the windowless core; tolerate as a success no-op.
result->Success();
} else if (method == "isInitialized") {
bool initialized = player_ && player_->IsInitialized();
result->Success(flutter::EncodableValue(initialized));
// --- Display mode matching ---
} else if (method == "getDisplayModes") {
// --- Display mode matching (video instance only) ---
} else if (!audio_only_ && method == "getDisplayModes") {
HWND hwnd = GetWindow();
auto modes = display_mode_manager_.EnumerateDisplayModes(hwnd);
flutter::EncodableList list;
@@ -418,11 +448,11 @@ void MpvPlayerPlugin::HandleMethodCall(
list.push_back(flutter::EncodableValue(DisplayModeToMap(mode)));
}
result->Success(flutter::EncodableValue(list));
} else if (method == "getCurrentDisplayMode") {
} else if (!audio_only_ && method == "getCurrentDisplayMode") {
HWND hwnd = GetWindow();
auto mode = display_mode_manager_.GetCurrentMode(hwnd);
result->Success(flutter::EncodableValue(DisplayModeToMap(mode)));
} else if (method == "setDisplayMode") {
} else if (!audio_only_ && method == "setDisplayMode") {
const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -438,17 +468,17 @@ void MpvPlayerPlugin::HandleMethodCall(
bool success =
display_mode_manager_.SetDisplayMode(hwnd, get_int("width"), get_int("height"), get_int("refreshRate"));
result->Success(flutter::EncodableValue(success));
} else if (method == "restoreDisplayMode") {
} else if (!audio_only_ && method == "restoreDisplayMode") {
HWND hwnd = GetWindow();
bool success = display_mode_manager_.RestoreOriginalMode(hwnd);
result->Success(flutter::EncodableValue(success));
} else if (method == "isHDRSupported") {
} else if (!audio_only_ && method == "isHDRSupported") {
HWND hwnd = GetWindow();
result->Success(flutter::EncodableValue(display_mode_manager_.IsHDRSupported(hwnd)));
} else if (method == "isHDREnabled") {
} else if (!audio_only_ && method == "isHDREnabled") {
HWND hwnd = GetWindow();
result->Success(flutter::EncodableValue(display_mode_manager_.IsHDREnabled(hwnd)));
} else if (method == "setSystemHDR") {
} else if (!audio_only_ && method == "setSystemHDR") {
const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -464,13 +494,13 @@ void MpvPlayerPlugin::HandleMethodCall(
HWND hwnd = GetWindow();
bool success = display_mode_manager_.SetHDREnabled(hwnd, enabled);
result->Success(flutter::EncodableValue(success));
} else if (method == "restoreSystemHDR") {
} else if (!audio_only_ && method == "restoreSystemHDR") {
HWND hwnd = GetWindow();
bool success = display_mode_manager_.RestoreOriginalHDRState(hwnd);
result->Success(flutter::EncodableValue(success));
} else if (method == "isModeChanged") {
} else if (!audio_only_ && method == "isModeChanged") {
result->Success(flutter::EncodableValue(display_mode_manager_.IsModeChanged()));
} else if (method == "isHDRChanged") {
} else if (!audio_only_ && method == "isHDRChanged") {
result->Success(flutter::EncodableValue(display_mode_manager_.IsHDRChanged()));
} else {
result->NotImplemented();
+18 -3
View File
@@ -13,20 +13,30 @@
#include <mutex>
#include <optional>
#include <queue>
#include <string>
#include "display_mode_manager.h"
#include "mpv_player.h"
// C-style registration function for the plugin.
// C-style registration functions for the video and audio-only plugin
// instances.
void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar);
void MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar);
namespace mpv {
class MpvPlayerPlugin : public flutter::Plugin {
public:
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar);
// |channel_name| is the method channel name; the event channel is
// |channel_name| + "/events". |audio_only| runs a windowless music core:
// no child HWND, no display-mode handling (see MpvPlayer).
static void RegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar, const std::string& channel_name = "com.plezy/mpv_player",
bool audio_only = false);
MpvPlayerPlugin(flutter::PluginRegistrarWindows* registrar);
MpvPlayerPlugin(
flutter::PluginRegistrarWindows* registrar, const std::string& channel_name = "com.plezy/mpv_player",
bool audio_only = false);
virtual ~MpvPlayerPlugin();
private:
@@ -43,6 +53,11 @@ class MpvPlayerPlugin : public flutter::Plugin {
flutter::PluginRegistrarWindows* registrar_;
DWORD platform_thread_id_;
const bool audio_only_;
// Per-instance wakeup message: the first window-proc delegate that handles
// a message consumes it, so the video and audio instances must not share
// one message id or one instance's wakeup would strand the other's queue.
const UINT platform_task_message_;
HWND flutter_window_ = nullptr;
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> method_channel_;
std::unique_ptr<flutter::EventChannel<flutter::EncodableValue>> event_channel_;