From 5fed12e02531b6481c413337fc86c91f84a75d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Michaj=C5=82ow?= Date: Mon, 10 Jun 2024 20:41:31 +0200 Subject: [PATCH] win32: add Media Control support Add support for SystemMediaTransportControls interface. This allows to control mpv from Windows media control ui. --- DOCS/interface-changes/media-controls.txt | 1 + DOCS/man/options.rst | 7 + meson.build | 4 + meson_options.txt | 3 + misc/path_utils.c | 2 + options/options.c | 3 + options/options.h | 1 + osdep/win32/meson.build | 8 + osdep/win32/smtc.cpp | 433 ++++++++++++++++++++++ osdep/win32/smtc.h | 29 ++ player/main.c | 6 + 11 files changed, 497 insertions(+) create mode 100644 DOCS/interface-changes/media-controls.txt create mode 100644 osdep/win32/meson.build create mode 100644 osdep/win32/smtc.cpp create mode 100644 osdep/win32/smtc.h diff --git a/DOCS/interface-changes/media-controls.txt b/DOCS/interface-changes/media-controls.txt new file mode 100644 index 0000000000..c7c7c0a351 --- /dev/null +++ b/DOCS/interface-changes/media-controls.txt @@ -0,0 +1 @@ +add `--media-controls` option diff --git a/DOCS/man/options.rst b/DOCS/man/options.rst index aa9987bba1..6b220ac88f 100644 --- a/DOCS/man/options.rst +++ b/DOCS/man/options.rst @@ -7420,6 +7420,13 @@ Miscellaneous .. warning:: Using realtime priority can cause system lockup. +``--media-controls=`` + (Windows only) + Enable integration of media control interface SystemMediaTransportControls. + If set to ``player``, only the player will use the controls. Setting it to + ``yes`` will also enable the controls for libmpv integrations. + (default: ``player``) + ``--force-media-title=`` Force the contents of the ``media-title`` property to this value. Useful for scripts which want to set a title, without overriding the user's diff --git a/meson.build b/meson.build index 81721b2d93..3bca70e637 100644 --- a/meson.build +++ b/meson.build @@ -530,6 +530,10 @@ if not posix and not features['win32-desktop'] 'osdep/terminal-dummy.c') endif +if win32 + subdir('osdep/win32') +endif + features += {'glob-posix': cc.has_function('glob', prefix: '#include ')} features += {'glob-win32': win32 and not features['glob-posix']} diff --git a/meson_options.txt b/meson_options.txt index 771e66334b..7ff9262d20 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -116,6 +116,9 @@ option('macos-touchbar', type: 'feature', value: 'auto', description: 'macOS Tou option('swift-build', type: 'feature', value: 'auto', description: 'macOS Swift build tools') option('swift-flags', type: 'string', description: 'Optional Swift compiler flags') +# Windows features +option('win32-smtc', type: 'feature', value: 'auto', description: 'Enable Media Control support') + # manpages option('html-build', type: 'feature', value: 'disabled', description: 'HTML manual generation') option('manpage-build', type: 'feature', value: 'auto', description: 'manpage generation') diff --git a/misc/path_utils.c b/misc/path_utils.c index 7252834e7e..4349fc13b3 100644 --- a/misc/path_utils.c +++ b/misc/path_utils.c @@ -156,6 +156,8 @@ char *mp_getcwd(void *talloc_ctx) char *mp_normalize_path(void *talloc_ctx, const char *path) { + assert(talloc_ctx && "mp_normalize_path requires talloc_ctx!"); + if (!path) return NULL; diff --git a/options/options.c b/options/options.c index 11abf51f98..b087e619f5 100644 --- a/options/options.c +++ b/options/options.c @@ -543,6 +543,8 @@ static const m_option_t mp_opts[] = { {"idle", IDLE_PRIORITY_CLASS}), .flags = UPDATE_PRIORITY}, #endif + {"media-controls", OPT_CHOICE(media_controls, + {"no", 0}, {"player", 1}, {"yes", 2})}, {"config", OPT_BOOL(load_config), .flags = CONF_PRE_PARSE}, {"config-dir", OPT_STRING(force_configdir), .flags = CONF_NOCFG | CONF_PRE_PARSE | M_OPT_FILE}, @@ -1039,6 +1041,7 @@ static const struct MPOpts mp_default_opts = { .osd_bar_visible = true, .screenshot_template = "mpv-shot%n", .play_dir = 1, + .media_controls = 1, .audiofile_auto_exts = (char *[]){ "aac", diff --git a/options/options.h b/options/options.h index 918b61fb8a..9125c3d650 100644 --- a/options/options.h +++ b/options/options.h @@ -344,6 +344,7 @@ typedef struct MPOpts { bool osd_bar_visible; int w32_priority; + int media_controls; struct bluray_opts *stream_bluray_opts; struct cdda_opts *stream_cdda_opts; diff --git a/osdep/win32/meson.build b/osdep/win32/meson.build new file mode 100644 index 0000000000..97d582ce4e --- /dev/null +++ b/osdep/win32/meson.build @@ -0,0 +1,8 @@ +add_languages('cpp', native: false) +cpp = meson.get_compiler('cpp') + +features += {'win32-smtc': cpp.has_header('winrt/base.h', required: get_option('win32-smtc'))} +if features['win32-smtc'] + dependencies += cpp.find_library('runtimeobject') + sources += meson.current_source_dir() / 'smtc.cpp' +endif diff --git a/osdep/win32/smtc.cpp b/osdep/win32/smtc.cpp new file mode 100644 index 0000000000..2ad8fa42c8 --- /dev/null +++ b/osdep/win32/smtc.cpp @@ -0,0 +1,433 @@ +/* + * This file is part of mpv. + * + * mpv is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * mpv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with mpv. If not, see . + */ + +#include "smtc.h" + +#include +#include +#include + +#include +#include +#include +#include + +extern "C" { +#include "common/msg.h" +#include "osdep/threads.h" +#include "player/client.h" +} + +EXTERN_C IMAGE_DOS_HEADER __ImageBase; +#define WM_MP_EVENT (WM_USER + 1) + +using namespace std::chrono_literals; +using namespace winrt::Windows::Media; +using winrt::Windows::Foundation::TimeSpan; + +struct mpv_deleter { + void operator()(void *ptr) const { + mpv_free(ptr); + } +}; +using mp_string = std::unique_ptr; + +template struct mp_fmt; +template<> struct mp_fmt { using type = int; }; +template<> struct mp_fmt { using type = int64_t; }; +template<> struct mp_fmt { using type = double; }; + +template +static inline std::optional::type> +mp_get_property(mpv_handle *mpv, const char *name) +{ + typename mp_fmt::type val; + if (mpv_get_property(mpv, name, F, &val) != MPV_ERROR_SUCCESS) + return std::nullopt; + return val; +} + +template struct mp_fmt_e; +template<> struct mp_fmt_e { static constexpr mpv_format value = MPV_FORMAT_FLAG; }; +template<> struct mp_fmt_e { static constexpr mpv_format value = MPV_FORMAT_FLAG; }; +template<> struct mp_fmt_e { static constexpr mpv_format value = MPV_FORMAT_INT64; }; +template<> struct mp_fmt_e { static constexpr mpv_format value = MPV_FORMAT_DOUBLE; }; + +template +static inline int mp_set_property(mpv_handle *mpv, const char *name, T &&val) +{ + using val_t = std::remove_reference_t; + typename mp_fmt::value>::type mpv_val = std::forward(val); + return mpv_set_property(mpv, name, mp_fmt_e::value, &mpv_val); +} + +struct smtc_ctx { + mp_log *log; + mpv_handle *mpv; + SystemMediaTransportControls smtc{ nullptr }; + std::atomic_bool close{ false }; + std::atomic hwnd{ nullptr }; +}; + +static void update_state(SystemMediaTransportControls &smtc, mpv_handle *mpv) +{ + auto closed = mp_get_property(mpv, "idle-active"); + if (!closed.value_or(false)) { + auto paused = mp_get_property(mpv, "pause"); + smtc.PlaybackStatus(paused.value_or(true) ? MediaPlaybackStatus::Paused : MediaPlaybackStatus::Playing); + smtc.IsPlayEnabled(true); + smtc.IsPauseEnabled(true); + smtc.IsStopEnabled(true); + auto ch_index = mp_get_property(mpv, "chapter"); + auto ch_count = mp_get_property(mpv, "chapter-list/count"); + auto pl_count = mp_get_property(mpv, "playlist-count"); + smtc.IsNextEnabled(pl_count > 1 || ch_count > ch_index.value_or(0)); + smtc.IsPreviousEnabled(pl_count > 1 || ch_index > 0); + smtc.IsRewindEnabled(true); + } else { + smtc.PlaybackStatus(MediaPlaybackStatus::Closed); + smtc.IsPlayEnabled(false); + smtc.IsPauseEnabled(false); + smtc.IsStopEnabled(false); + smtc.IsNextEnabled(false); + smtc.IsPreviousEnabled(false); + smtc.IsRewindEnabled(false); + } + + auto shuffle = mp_get_property(mpv, "shuffle"); + smtc.ShuffleEnabled(shuffle.value_or(false)); + + auto speed = mp_get_property(mpv, "speed"); + smtc.PlaybackRate(speed.value_or(1.0)); + + mp_string loop_file_opt{ mpv_get_property_string(mpv, "loop-file") }; + bool loop_file = loop_file_opt && strcmp(loop_file_opt.get(), "no"); + mp_string loop_playlist_opt{ mpv_get_property_string(mpv, "loop-playlist") }; + bool loop_playlist = loop_playlist_opt && strcmp(loop_playlist_opt.get(), "no"); + if (loop_file) { + smtc.AutoRepeatMode(MediaPlaybackAutoRepeatMode::Track); + } else if (loop_playlist) { + smtc.AutoRepeatMode(MediaPlaybackAutoRepeatMode::List); + } else { + smtc.AutoRepeatMode(MediaPlaybackAutoRepeatMode::None); + } + + auto pos = mp_get_property(mpv, "time-pos"); + auto duration = mp_get_property(mpv, "duration"); + + if (!pos || !duration) + return; + + SystemMediaTransportControlsTimelineProperties tl; + tl.StartTime(0s); + tl.MinSeekTime(0s); + tl.Position(std::chrono::duration_cast(std::chrono::duration(*pos))); + tl.MaxSeekTime(std::chrono::duration_cast(std::chrono::duration(*duration))); + tl.EndTime(std::chrono::duration_cast(std::chrono::duration(*duration))); + smtc.UpdateTimelineProperties(tl); +} + +static void update_metadata(SystemMediaTransportControls &smtc, smtc_ctx &ctx) +{ + auto *mpv = ctx.mpv; + auto updater = smtc.DisplayUpdater(); + updater.ClearAll(); + + auto image_opt = mp_get_property(mpv, "current-tracks/video/image"); + bool video = bool(image_opt); + bool image = image_opt.value_or(false); + auto audio = mp_get_property(mpv, "current-tracks/audio/selected"); + + if (!video && !image && !audio) + return; + + mp_string title{ mpv_get_property_osd_string(mpv, "media-title") }; + if (video && !image) { + updater.Type(MediaPlaybackType::Video); + const auto &props = updater.VideoProperties(); + if (title) + props.Title(winrt::to_hstring(title.get())); + auto ch_index = mp_get_property(mpv, "chapter").value_or(-1); + if (ch_index >= 0) { + mp_string ch_title { + mpv_get_property_string(mpv, std::format("chapter-list/{}/title", ch_index).c_str()) + }; + if (ch_title) { + auto ch_count = mp_get_property(mpv, "chapter-list/count").value_or(0); + props.Subtitle(winrt::to_hstring(std::format("{} ({}/{})", ch_title.get(), ch_index + 1, ch_count))); + } + } + } else if (image && !audio) { + updater.Type(MediaPlaybackType::Image); + const auto &props = updater.ImageProperties(); + if (title) + props.Title(winrt::to_hstring(title.get())); + } else { + updater.Type(MediaPlaybackType::Music); + const auto &props = updater.MusicProperties(); + if (title) + props.Title(winrt::to_hstring(title.get())); + if (mp_string str{ mpv_get_property_string(mpv, "metadata/by-key/Album_Artist") }) + props.AlbumArtist(winrt::to_hstring(str.get())); + if (mp_string str{ mpv_get_property_string(mpv, "metadata/by-key/Album") }) + props.AlbumTitle(winrt::to_hstring(str.get())); + if (mp_string str{ mpv_get_property_string(mpv, "metadata/by-key/Album_Track_Count") }) + props.AlbumTrackCount(std::atoi(str.get())); + if (mp_string str{ mpv_get_property_string(mpv, "metadata/by-key/Artist") }) + props.Artist(winrt::to_hstring(str.get())); + if (mp_string str{ mpv_get_property_string(mpv, "metadata/by-key/Track") }) + props.TrackNumber(std::atoi(str.get())); + } + + updater.Update(); +} + +static void handle_mp_event(smtc_ctx *ctx, mpv_event *event) +{ + if (!ctx || !ctx->smtc || !ctx->mpv || ctx->close) + return; + + try { + update_state(ctx->smtc, ctx->mpv); + if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) { + auto &prop = *static_cast(event->data); + if (!strcmp(prop.name, "time-pos") || !strcmp(prop.name, "duration")) + return; + } + update_metadata(ctx->smtc, *ctx); + } catch (const winrt::hresult_error& e) { + MP_VERBOSE(ctx, "%s: 0x%x - %ls\n", __func__, int32_t(e.code()), e.message().c_str()); + } +} + +static LRESULT CALLBACK WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) +{ + if (uMsg == WM_DESTROY) + PostQuitMessage(0); + + smtc_ctx *ctx = reinterpret_cast(GetWindowLongPtrW(hWnd, GWLP_USERDATA)); + + switch (uMsg) + { + case WM_MP_EVENT: + handle_mp_event(ctx, reinterpret_cast(lParam)); + return 0; + case WM_SETFOCUS: + if (!ctx) + return 0; + if (auto wid { mp_get_property(ctx->mpv, "window-id") }) + SetFocus(HWND(*wid)); + return 0; + case WM_ACTIVATE: + if (!ctx) + return 0; + if (auto wid { mp_get_property(ctx->mpv, "window-id") }) { + if (IsIconic(HWND(*wid))) + ShowWindow(HWND(*wid), SW_RESTORE); + BringWindowToTop(HWND(*wid)); + } + return 0; + } + + return DefWindowProc(hWnd, uMsg, wParam, lParam); +} + +static MP_THREAD_VOID win_event_loop_fn(void *arg) +{ + mp_thread_set_name("smtc/win"); + auto &ctx = *static_cast(arg); + auto *mpv = ctx.mpv; + + WNDCLASS wc = { + .lpfnWndProc = WindowProc, + .hInstance = HINSTANCE(&__ImageBase), + .hIcon = LoadIconW(HINSTANCE(&__ImageBase), L"IDI_ICON1"), + .lpszClassName = L"mpv-smtc" + }; + RegisterClassW(&wc); + + try { + // Dummy window is used to allow SMTC to work also in audio only mode, + // where VO may not be created. + ctx.hwnd = CreateWindowExW(0, wc.lpszClassName, L"mpv smtc", + WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, + nullptr, nullptr, wc.hInstance, nullptr); + if (!ctx.hwnd) + winrt::throw_last_error(); + + SystemMediaTransportControls &smtc = ctx.smtc; + auto interop = winrt::get_activation_factory(); + HRESULT hr = interop->GetForWindow(ctx.hwnd, + winrt::guid_of(), + winrt::put_abi(smtc)); + if (FAILED(hr)) + winrt::throw_hresult(hr); + SetWindowLongPtrW(ctx.hwnd, GWLP_USERDATA, LONG_PTR(&ctx)); + + smtc.IsEnabled(true); + + smtc.ButtonPressed([&](const SystemMediaTransportControls &, + const SystemMediaTransportControlsButtonPressedEventArgs &args) { + switch (args.Button()) { + case SystemMediaTransportControlsButton::Play: + mp_set_property(mpv, "pause", false); + break; + case SystemMediaTransportControlsButton::Pause: + mp_set_property(mpv, "pause", true); + break; + case SystemMediaTransportControlsButton::Stop: + mpv_command_string(mpv, "stop"); + break; + case SystemMediaTransportControlsButton::Next: { + auto ch_index = mp_get_property(mpv, "chapter").value_or(0); + auto ch_count = mp_get_property(mpv, "chapter-list/count"); + // mpv allows to jump past last chapter + mpv_command_string(mpv, ch_index < ch_count ? "add chapter 1" : "playlist-next"); + break; + } + case SystemMediaTransportControlsButton::Previous: { + auto ch_index = mp_get_property(mpv, "chapter"); + mpv_command_string(mpv, ch_index > 0 ? "add chapter -1" : "playlist-prev"); + break; + } + default: + break; + } + }); + smtc.PlaybackPositionChangeRequested([&](const SystemMediaTransportControls &, + const PlaybackPositionChangeRequestedEventArgs &args) { + auto position = args.RequestedPlaybackPosition(); + auto pos = std::chrono::duration_cast>(position).count(); + mp_set_property(mpv, "time-pos", pos); + }); + smtc.PlaybackRateChangeRequested([&](const SystemMediaTransportControls &, + const PlaybackRateChangeRequestedEventArgs &args) { + mp_set_property(mpv, "speed", args.RequestedPlaybackRate()); + }); + smtc.ShuffleEnabledChangeRequested([&](const SystemMediaTransportControls &, + const ShuffleEnabledChangeRequestedEventArgs &args) { + mp_set_property(mpv, "shuffle", args.RequestedShuffleEnabled()); + }); + smtc.AutoRepeatModeChangeRequested([&](const SystemMediaTransportControls &, + const AutoRepeatModeChangeRequestedEventArgs &args) { + bool loop_file = false, loop_playlist = false; + switch (args.RequestedAutoRepeatMode()) { + case MediaPlaybackAutoRepeatMode::Track: + loop_file = true; + break; + case MediaPlaybackAutoRepeatMode::List: + loop_playlist = true; + break; + case MediaPlaybackAutoRepeatMode::None: + break; + } + mp_set_property(mpv, "loop-file", loop_file); + mp_set_property(mpv, "loop-playlist", loop_playlist); + }); + + MSG msg; + while(BOOL ret = GetMessageW(&msg, nullptr, 0, 0)) { + if (ret == -1) + winrt::throw_last_error(); + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } catch (const winrt::hresult_error& e) { + MP_ERR(&ctx, "%s: 0x%x - %ls\n", __func__, int32_t(e.code()), e.message().c_str()); + } + + ctx.close = true; + mpv_wakeup(mpv); + HWND hwnd = ctx.hwnd; + ctx.hwnd = nullptr; + DestroyWindow(hwnd); + UnregisterClassW(wc.lpszClassName, HINSTANCE(&__ImageBase)); + + MP_THREAD_RETURN(); +} + +static MP_THREAD_VOID mpv_event_loop_fn(void *arg) +{ + mp_thread_set_name("smtc/mpv"); + auto mpv = static_cast(arg); + smtc_ctx ctx = { + .log = mp_client_get_log(mpv), + .mpv = mpv + }; + + // Create a dedicated window and event loop. We could use the mpv main window, + // but it is not always available, especially in audio-only/console mode. + mp_thread win_event_loop; + if (mp_thread_create(&win_event_loop, win_event_loop_fn, &ctx)) { + MP_ERR(&ctx, "Failed to create window event thread!\n"); + goto error; + } + + // It is recommended that you keep the system controls in sync with your + // media playback by updating these properties approximately every 5 seconds + // during playback and again whenever the state of playback changes, such as + // pausing or seeking to a new position. + // https://learn.microsoft.com/windows/uwp/audio-video-camera/system-media-transport-controls + // For simplicity we observe time-pos and duration as integers, so we get + // update every second, faster than recommended, but should be fine. + + mpv_observe_property(mpv, 0, "current-tracks", MPV_FORMAT_NODE_MAP); + mpv_observe_property(mpv, 0, "duration", MPV_FORMAT_INT64); + mpv_observe_property(mpv, 0, "idle-active", MPV_FORMAT_FLAG); + mpv_observe_property(mpv, 0, "media-title", MPV_FORMAT_STRING); + mpv_observe_property(mpv, 0, "metadata", MPV_FORMAT_NODE_MAP); + mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG); + mpv_observe_property(mpv, 0, "shuffle", MPV_FORMAT_DOUBLE); + mpv_observe_property(mpv, 0, "speed", MPV_FORMAT_DOUBLE); + mpv_observe_property(mpv, 0, "time-pos", MPV_FORMAT_INT64); + // TODO: Options are not observable, fix me! + mpv_observe_property(mpv, 0, "loop-file", MPV_FORMAT_DOUBLE); + mpv_observe_property(mpv, 0, "loop-playlist", MPV_FORMAT_DOUBLE); + + while (!ctx.close) { + mpv_event *event = mpv_wait_event(mpv, -1); + if (ctx.close) + break; + if (event->event_id == MPV_EVENT_SHUTDOWN) { + HWND hwnd = ctx.hwnd; + if (hwnd) + PostMessageW(hwnd, WM_CLOSE, 0, 0); + break; + } + if (event->event_id == MPV_EVENT_PROPERTY_CHANGE || + event->event_id == MPV_EVENT_PLAYBACK_RESTART) + { + HWND hwnd = ctx.hwnd; + if (hwnd) + SendMessageW(hwnd, WM_MP_EVENT, 0, LPARAM(event)); + } + } + mp_thread_join(win_event_loop); + +error: + mpv_destroy(mpv); + MP_THREAD_RETURN(); +} + +void mp_smtc_init(mpv_handle *mpv) +{ + mp_thread mpv_event_loop; + if (!mp_thread_create(&mpv_event_loop, mpv_event_loop_fn, mpv)) + mp_thread_detach(mpv_event_loop); +} diff --git a/osdep/win32/smtc.h b/osdep/win32/smtc.h new file mode 100644 index 0000000000..c5e96b3156 --- /dev/null +++ b/osdep/win32/smtc.h @@ -0,0 +1,29 @@ +/* + * This file is part of mpv. + * + * mpv is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * mpv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with mpv. If not, see . + */ + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct mpv_handle mpv_handle; +void mp_smtc_init(mpv_handle *client); + +#ifdef __cplusplus +} +#endif diff --git a/player/main.c b/player/main.c index f212abad1c..5a3bac2a68 100644 --- a/player/main.c +++ b/player/main.c @@ -36,6 +36,7 @@ #include "osdep/threads.h" #include "osdep/timer.h" #include "osdep/main-fn.h" +#include "osdep/win32/smtc.h" #include "common/av_log.h" #include "common/codecs.h" @@ -399,6 +400,11 @@ int mp_initialize(struct MPContext *mpctx, char **options) cocoa_set_mpv_handle(ctx); #endif +#if defined(HAVE_WIN32_SMTC) && HAVE_WIN32_SMTC + if (opts->media_controls == 2 || (mpctx->is_cli && opts->media_controls == 1)) + mp_smtc_init(mp_new_client(mpctx->clients, "SystemMediaTransportControls")); +#endif + if (opts->encode_opts->file && opts->encode_opts->file[0]) { mpctx->encode_lavc_ctx = encode_lavc_init(mpctx->global); if(!mpctx->encode_lavc_ctx) {