diff --git a/FluentFlyoutWPF/Classes/ExternalVolumeMonitor.cs b/FluentFlyoutWPF/Classes/ExternalVolumeMonitor.cs new file mode 100644 index 000000000..c596e6706 --- /dev/null +++ b/FluentFlyoutWPF/Classes/ExternalVolumeMonitor.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2024-2026 The FluentFlyout Authors +// SPDX-License-Identifier: GPL-3.0-or-later + +using NAudio.CoreAudioApi; + +namespace FluentFlyoutWPF.Classes; + +public class ExternalVolumeMonitor : IDisposable +{ + private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + + private readonly int _localInputGracePeriodMs; + private MMDevice? _device; + private float _lastVolume; + private bool _lastMuted; + + public event EventHandler? ExternalVolumeChanged; + + public ExternalVolumeMonitor(int localInputGracePeriodMs) + { + _localInputGracePeriodMs = localInputGracePeriodMs; + + AudioDeviceMonitor.Instance.DefaultDeviceChanged += OnDefaultDeviceChanged; + AttachDevice(AudioDeviceMonitor.Instance.GetDefaultRenderDevice()); + } + + private void AttachDevice(MMDevice? device) + { + DetachDevice(); + + if (device == null) + return; + + try + { + _lastVolume = device.AudioEndpointVolume.MasterVolumeLevelScalar; + _lastMuted = device.AudioEndpointVolume.Mute; + device.AudioEndpointVolume.OnVolumeNotification += OnVolumeNotification; + _device = device; + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to watch volume of device {0}", device.FriendlyName); + device.Dispose(); + } + } + + private void DetachDevice() + { + if (_device == null) + return; + + try + { + _device.AudioEndpointVolume.OnVolumeNotification -= OnVolumeNotification; + _device.Dispose(); + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to stop watching device volume"); + } + finally + { + _device = null; + } + } + + private void OnDefaultDeviceChanged(object? sender, DefaultDeviceChangedEventArgs e) + { + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + AttachDevice(AudioDeviceMonitor.Instance.GetDeviceById(e.DeviceId)); + }); + } + + private void OnVolumeNotification(AudioVolumeNotificationData data) + { + // the volume mixer writes the same volume back when syncing + bool changed = MathF.Abs(data.MasterVolume - _lastVolume) > 0.001f || data.Muted != _lastMuted; + _lastVolume = data.MasterVolume; + _lastMuted = data.Muted; + + if (!changed || LocalInputDetector.HadInputWithin(_localInputGracePeriodMs)) + return; + +#if DEBUG + Logger.Debug("External volume change detected: {0:P0}, muted: {1}", data.MasterVolume, data.Muted); +#endif + ExternalVolumeChanged?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() + { + AudioDeviceMonitor.Instance.DefaultDeviceChanged -= OnDefaultDeviceChanged; + DetachDevice(); + + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/FluentFlyoutWPF/Classes/LocalInputDetector.cs b/FluentFlyoutWPF/Classes/LocalInputDetector.cs new file mode 100644 index 000000000..4d450f612 --- /dev/null +++ b/FluentFlyoutWPF/Classes/LocalInputDetector.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2024-2026 The FluentFlyout Authors +// SPDX-License-Identifier: GPL-3.0-or-later + +using System.Runtime.InteropServices; +using static FluentFlyout.Classes.NativeMethods; + +namespace FluentFlyoutWPF.Classes; + +internal static class LocalInputDetector +{ + public static bool HadInputWithin(int milliseconds) + { + var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf() }; + if (!GetLastInputInfo(ref info)) + return false; + + uint elapsed = unchecked((uint)Environment.TickCount - info.dwTime); + return elapsed < milliseconds; + } +} \ No newline at end of file diff --git a/FluentFlyoutWPF/Classes/NativeMethods.cs b/FluentFlyoutWPF/Classes/NativeMethods.cs index baad44ec4..0076ccc77 100644 --- a/FluentFlyoutWPF/Classes/NativeMethods.cs +++ b/FluentFlyoutWPF/Classes/NativeMethods.cs @@ -69,6 +69,7 @@ public static partial class NativeMethods internal const int APPCOMMAND_MEDIA_STOP = 13; internal const int APPCOMMAND_MEDIA_PLAY_PAUSE = 14; internal const int FAPPCOMMAND_KEY = 0x0000; + internal const int FAPPCOMMAND_OEM = 0x1000; #endregion @@ -215,6 +216,13 @@ internal struct WindowCompositionAttributeData public int SizeOfData; } + [StructLayout(LayoutKind.Sequential)] + internal struct LASTINPUTINFO + { + public uint cbSize; + public uint dwTime; + } + #endregion #region Delegates @@ -342,6 +350,10 @@ internal struct WindowCompositionAttributeData [LibraryImport("user32.dll", SetLastError = true)] internal static partial IntPtr GetForegroundWindow(); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetLastInputInfo(ref LASTINPUTINFO plii); #endregion #region gdi32.dll diff --git a/FluentFlyoutWPF/MainWindow.HeadsetControls.cs b/FluentFlyoutWPF/MainWindow.HeadsetControls.cs new file mode 100644 index 000000000..e780d6ed1 --- /dev/null +++ b/FluentFlyoutWPF/MainWindow.HeadsetControls.cs @@ -0,0 +1,120 @@ +// Copyright (c) 2024-2026 The FluentFlyout Authors +// SPDX-License-Identifier: GPL-3.0-or-later + +using FluentFlyout.Classes.Settings; +using FluentFlyoutWPF.Classes; +using Windows.Media.Control; +using static FluentFlyout.Classes.NativeMethods; +using static WindowsMediaController.MediaManager; + +namespace FluentFlyoutWPF; + +public partial class MainWindow +{ + private const int LocalInputGracePeriodMs = 500; + + private ExternalVolumeMonitor? _externalVolumeMonitor; + private readonly Dictionary _lastPlaybackStatuses = []; + + private static bool HeadsetControlsEnabled => SettingsManager.Current.MediaFlyoutHeadsetControlsEnabled; + + private void InitializeHeadsetControls() + { + foreach (var session in mediaManager.CurrentMediaSessions.Values) + UpdateLastPlaybackStatus(session); + + mediaManager.OnAnyPlaybackStateChanged += HeadsetControls_OnPlaybackStateChanged; + mediaManager.OnAnySessionClosed += HeadsetControls_OnSessionClosed; + + _externalVolumeMonitor = new ExternalVolumeMonitor(LocalInputGracePeriodMs); + _externalVolumeMonitor.ExternalVolumeChanged += HeadsetControls_OnExternalVolumeChanged; + } + + private void DisposeHeadsetControls() + { + mediaManager.OnAnyPlaybackStateChanged -= HeadsetControls_OnPlaybackStateChanged; + mediaManager.OnAnySessionClosed -= HeadsetControls_OnSessionClosed; + + if (_externalVolumeMonitor != null) + { + _externalVolumeMonitor.ExternalVolumeChanged -= HeadsetControls_OnExternalVolumeChanged; + _externalVolumeMonitor.Dispose(); + _externalVolumeMonitor = null; + } + } + + private static bool IsHeadsetAppCommand(int device) + { + if (device != FAPPCOMMAND_OEM) + return false; + +#if DEBUG + Logger.Debug("App command received from OEM device, headset controls enabled: " + HeadsetControlsEnabled); +#endif + return HeadsetControlsEnabled; + } + + private void HeadsetControls_OnExternalVolumeChanged(object? sender, EventArgs e) + { + if (!HeadsetControlsEnabled) + return; + + Dispatcher.BeginInvoke(() => + { + if (!SettingsManager.Current.MediaFlyoutVolumeKeysExcluded) + TryShowMediaFlyoutDebounced(); + + ShowVolumeFlyout(); + }); + } + + private void HeadsetControls_OnPlaybackStateChanged(MediaSession mediaSession, GlobalSystemMediaTransportControlsSessionPlaybackInfo? playbackInfo = null) + { + var (previousStatus, currentStatus) = UpdateLastPlaybackStatus(mediaSession, playbackInfo); + + if (!HeadsetControlsEnabled) + return; + + bool isPlayPauseToggle = + (previousStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && currentStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused) || + (previousStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused && currentStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing); + + if (!isPlayPauseToggle || LocalInputDetector.HadInputWithin(LocalInputGracePeriodMs)) + return; + + Dispatcher.BeginInvoke(() => + { + if (GetActiveMediaSession()?.Id != mediaSession.Id) + return; + +#if DEBUG + Logger.Debug("Play/pause without key press detected: " + mediaSession.Id + " " + currentStatus); +#endif + TryShowMediaFlyoutDebounced(); + }); + } + + private void HeadsetControls_OnSessionClosed(MediaSession mediaSession) + { + lock (_lastPlaybackStatuses) + _lastPlaybackStatuses.Remove(mediaSession.Id); + } + + private (GlobalSystemMediaTransportControlsSessionPlaybackStatus? Previous, GlobalSystemMediaTransportControlsSessionPlaybackStatus? Current) + UpdateLastPlaybackStatus(MediaSession mediaSession, GlobalSystemMediaTransportControlsSessionPlaybackInfo? playbackInfo = null) + { + // sometimes mediaSession.ControlSession can be null + var currentStatus = playbackInfo?.PlaybackStatus ?? mediaSession.ControlSession?.GetPlaybackInfo()?.PlaybackStatus; + if (currentStatus == null) + return (null, null); + + lock (_lastPlaybackStatuses) + { + GlobalSystemMediaTransportControlsSessionPlaybackStatus? previousStatus = + _lastPlaybackStatuses.TryGetValue(mediaSession.Id, out var lastStatus) ? lastStatus : null; + + _lastPlaybackStatuses[mediaSession.Id] = currentStatus.Value; + return (previousStatus, currentStatus); + } + } +} \ No newline at end of file diff --git a/FluentFlyoutWPF/MainWindow.xaml.cs b/FluentFlyoutWPF/MainWindow.xaml.cs index 3eb8a9fe3..a3bb6e6fc 100644 --- a/FluentFlyoutWPF/MainWindow.xaml.cs +++ b/FluentFlyoutWPF/MainWindow.xaml.cs @@ -183,6 +183,8 @@ public MainWindow() mediaManager.OnAnyTimelinePropertyChanged += MediaManager_OnAnyTimelinePropertyChanged; mediaManager.OnAnySessionClosed += MediaManager_OnAnySessionClosed; + InitializeHeadsetControls(); + WM_TASKBARCREATED = RegisterWindowMessage("TaskbarCreated"); WM_SHELLHOOK = RegisterWindowMessage("SHELLHOOK"); RegisterShellHookWindow(new WindowInteropHelper(this).Handle); @@ -1529,6 +1531,8 @@ private void CleanupResources() mediaManager.OnAnyTimelinePropertyChanged -= MediaManager_OnAnyTimelinePropertyChanged; mediaManager.OnAnySessionClosed -= MediaManager_OnAnySessionClosed; + DisposeHeadsetControls(); + // dispose managed resources _positionTimer?.Change(Timeout.Infinite, Timeout.Infinite); _positionTimer?.Dispose(); @@ -1742,8 +1746,9 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand return 0; bool isKeyCommand = device == FAPPCOMMAND_KEY; + bool isHeadsetCommand = IsHeadsetAppCommand(device); - if (!isKeyCommand) + if (!isKeyCommand && !isHeadsetCommand) return 0; bool result = false; diff --git a/FluentFlyoutWPF/Pages/MediaFlyoutPage.xaml b/FluentFlyoutWPF/Pages/MediaFlyoutPage.xaml index dec433404..fd63a72e1 100644 --- a/FluentFlyoutWPF/Pages/MediaFlyoutPage.xaml +++ b/FluentFlyoutWPF/Pages/MediaFlyoutPage.xaml @@ -33,6 +33,7 @@ + @@ -166,7 +167,7 @@ - + @@ -175,6 +176,15 @@ + + + + + + + + + diff --git a/FluentFlyoutWPF/Resources/Localization/Dictionary-en-US.xaml b/FluentFlyoutWPF/Resources/Localization/Dictionary-en-US.xaml index 9831367d4..97048d152 100644 --- a/FluentFlyoutWPF/Resources/Localization/Dictionary-en-US.xaml +++ b/FluentFlyoutWPF/Resources/Localization/Dictionary-en-US.xaml @@ -14,6 +14,8 @@ Display a flyout when you press a media key Exclude Volume Keys Do not display the flyout when pressing volume up/down/mute + Headphone Controls + Also display the flyout when using the media or volume controls on Bluetooth headphones and other external devices Choose from a preset of modern background blur styles None Style 1 (Cover Glow) diff --git a/FluentFlyoutWPF/ViewModels/UserSettings.cs b/FluentFlyoutWPF/ViewModels/UserSettings.cs index e149c5fc8..a69fdcd65 100644 --- a/FluentFlyoutWPF/ViewModels/UserSettings.cs +++ b/FluentFlyoutWPF/ViewModels/UserSettings.cs @@ -241,6 +241,12 @@ public string LockKeysDurationText [ObservableProperty] public partial bool MediaFlyoutVolumeKeysExcluded { get; set; } + /// + /// Display media flyout when media or volume is changed from headphones or other external devices + /// + [ObservableProperty] + public partial bool MediaFlyoutHeadsetControlsEnabled { get; set; } + /// /// Use symbol-style tray icon /// @@ -729,6 +735,7 @@ public UserSettings() MediaFlyoutEnabled = true; MediaFlyoutAlwaysDisplay = false; MediaFlyoutVolumeKeysExcluded = false; + MediaFlyoutHeadsetControlsEnabled = false; NIconSymbol = false; NIconHide = false; DisableIfFullscreen = true;