Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions FluentFlyoutWPF/Classes/ExternalVolumeMonitor.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
20 changes: 20 additions & 0 deletions FluentFlyoutWPF/Classes/LocalInputDetector.cs
Original file line number Diff line number Diff line change
@@ -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<LASTINPUTINFO>() };
if (!GetLastInputInfo(ref info))
return false;

uint elapsed = unchecked((uint)Environment.TickCount - info.dwTime);
return elapsed < milliseconds;
}
}
12 changes: 12 additions & 0 deletions FluentFlyoutWPF/Classes/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions FluentFlyoutWPF/MainWindow.HeadsetControls.cs
Original file line number Diff line number Diff line change
@@ -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<string, GlobalSystemMediaTransportControlsSessionPlaybackStatus> _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);
}
}
}
7 changes: 6 additions & 1 deletion FluentFlyoutWPF/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion FluentFlyoutWPF/Pages/MediaFlyoutPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,24">
<Border CornerRadius="10" ClipToBounds="True" Width="164" Height="98">
Expand Down Expand Up @@ -166,7 +167,7 @@
</ui:CardControl.Header>
<ui:ToggleSwitch Name="PauseOtherSessionsEnabledSwitch" IsChecked="{Binding PauseOtherSessionsEnabled, Mode=TwoWay}" />
</ui:CardControl>
<ui:CardControl x:Name="MediaFlyoutExcludeVolumeTitleCard" Tag="Indexable" Grid.Row="15" Icon="{ui:SymbolIcon SpeakerOff24}" Margin="0,0,0,76">
<ui:CardControl x:Name="MediaFlyoutExcludeVolumeTitleCard" Tag="Indexable" Grid.Row="15" Icon="{ui:SymbolIcon SpeakerOff24}" Margin="0,0,0,3">
<ui:CardControl.Header>
<StackPanel Orientation="Vertical">
<TextBlock Text="{DynamicResource MediaFlyoutExcludeVolumeTitle}" FontSize="14" FontWeight="Regular" VerticalAlignment="Center" />
Expand All @@ -175,6 +176,15 @@
</ui:CardControl.Header>
<ui:ToggleSwitch Name="MediaFlyoutVolumeKeysExcludedSwitch" IsChecked="{Binding MediaFlyoutVolumeKeysExcluded, Mode=TwoWay}" />
</ui:CardControl>
<ui:CardControl x:Name="MediaFlyoutHeadsetControlsTitleCard" Tag="Indexable" Grid.Row="16" Icon="{ui:SymbolIcon Headphones24}" Margin="0,0,0,76">
<ui:CardControl.Header>
<StackPanel Orientation="Vertical">
<TextBlock Text="{DynamicResource MediaFlyoutHeadsetControlsTitle}" FontSize="14" FontWeight="Regular" VerticalAlignment="Center" />
<TextBlock Text="{DynamicResource MediaFlyoutHeadsetControlsDescription}" FontSize="12" Opacity="0.5" />
</StackPanel>
</ui:CardControl.Header>
<ui:ToggleSwitch Name="MediaFlyoutHeadsetControlsEnabledSwitch" IsChecked="{Binding MediaFlyoutHeadsetControlsEnabled, Mode=TwoWay}" />
</ui:CardControl>
</Grid>
</StackPanel>
</Page>
2 changes: 2 additions & 0 deletions FluentFlyoutWPF/Resources/Localization/Dictionary-en-US.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<System:String x:Key="TextBlockText3">Display a flyout when you press a media key</System:String>
<System:String x:Key="MediaFlyoutExcludeVolumeTitle">Exclude Volume Keys</System:String>
<System:String x:Key="MediaFlyoutExcludeVolumeDescription">Do not display the flyout when pressing volume up/down/mute</System:String>
<System:String x:Key="MediaFlyoutHeadsetControlsTitle">Headphone Controls</System:String>
<System:String x:Key="MediaFlyoutHeadsetControlsDescription">Also display the flyout when using the media or volume controls on Bluetooth headphones and other external devices</System:String>
<System:String x:Key="TextBlockText2">Choose from a preset of modern background blur styles</System:String>
<System:String x:Key="BackgroundBlurOption1" xml:space="preserve"> None</System:String>
<System:String x:Key="BackgroundBlurOption2" xml:space="preserve"> Style 1 (Cover Glow)</System:String>
Expand Down
7 changes: 7 additions & 0 deletions FluentFlyoutWPF/ViewModels/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ public string LockKeysDurationText
[ObservableProperty]
public partial bool MediaFlyoutVolumeKeysExcluded { get; set; }

/// <summary>
/// Display media flyout when media or volume is changed from headphones or other external devices
/// </summary>
[ObservableProperty]
public partial bool MediaFlyoutHeadsetControlsEnabled { get; set; }

/// <summary>
/// Use symbol-style tray icon
/// </summary>
Expand Down Expand Up @@ -729,6 +735,7 @@ public UserSettings()
MediaFlyoutEnabled = true;
MediaFlyoutAlwaysDisplay = false;
MediaFlyoutVolumeKeysExcluded = false;
MediaFlyoutHeadsetControlsEnabled = false;
NIconSymbol = false;
NIconHide = false;
DisableIfFullscreen = true;
Expand Down
Loading