封面图
玉清滴风小竹楼 玉清滴风小竹楼
今朝剑指叠云处

友链

先看效果图: win11: win10:

大致思路是:通过反射获取Popup内部的原生窗口句柄,然后通过前文已经实现的WindowMaterial类来应用窗口特效;对于ToolTip,为了保持其易用性,我使用了附加属性+全局样式的方式来实现,ToolTip也是一个特殊的Popup.
前文链接:WPF 模拟UWP原生窗口样式——亚克力|云母材质、自定义标题栏样式、原生DWM动画 (附我封装好的类)
本文的Demo:
::github{repo="TwilightLemon/WindowEffectTest"}

一、获取原生窗口句柄

通过查阅.NET源码得知,Popup内部通过一个类型为PopupSecurityHelper的私有字段_secHelper来管理窗口hWnd,并且在创建完成之时会触发Popup.Opened事件。
通过反射来获取窗口句柄:

const BindingFlags privateInstanceFlag = BindingFlags.NonPublic | BindingFlags.Instance;
public static IntPtr GetNativeWindowHwnd(this Popup popup)
{
    //获取Popup内部的_secHelper字段Info
    var field = typeof(Popup).GetField("_secHelper", privateInstanceFlag);
    if (field != null)
    {
        //获取popup的_secHelper字段值
        if (field.GetValue(popup) is { } _secHelper)
        {
            //获取_secHelper的Handle属性Info
            if (_secHelper.GetType().GetProperty("Handle", privateInstanceFlag) is { } prop)
            {
                if (prop.GetValue(_secHelper) is IntPtr handle)
                {
                    //返回句柄
                    return handle;
                }
            }
        }
    }
    //未找到
    return IntPtr.Zero;
}

同样地,能在ToolTip内部找到私有字段_parentPopup

public static IntPtr GetNativeWindowHwnd(this ToolTip tip)
{
    var field=tip.GetType().GetField("_parentPopup", privateInstanceFlag);
    if (field != null)
    {
        if(field.GetValue(tip) is Popup{ } popup)
        {
            return popup.GetNativeWindowHwnd();
        }
    }
    return IntPtr.Zero;
}
二、应用WindowMaterial特效

有了窗口句柄那么一切都好办了,直接调用我封装好的WindowMaterial类,如果你想了解更多请查看前文。

public static void SetPopupWindowMaterial(IntPtr hwnd,Color compositionColor,
     MaterialApis.WindowCorner corner= MaterialApis.WindowCorner.Round)
{
    if (hwnd != IntPtr.Zero)
    {
        int hexColor = compositionColor.ToHexColor();
        var hwndSource = HwndSource.FromHwnd(hwnd);
        //----
        MaterialApis.SetWindowProperties(hwndSource, 0);
        MaterialApis.SetWindowComposition(hwnd, true, hexColor);
        //----
        MaterialApis.SetWindowCorner(hwnd, corner);
    }
}

根据微软的设计规范,这里默认对普通Popup使用圆角,对ToolTip使用小圆角,使用亚克力材质并附加compositionColor。
在github中获取完整的WindowMaterial.cs,我可能会不定期地更新它:WindowEffectTest/WindowMaterial.cs at master · TwilightLemon/WindowEffectTest (github.com)

如果你想使用Mica或MicaAlt等材质则将上面框起来的代码替换为:

MaterialApis.SetWindowProperties(hwndSource, -1);
MaterialApis.SetBackDropType(hwnd, MaterialType.Mica);
MaterialApis.SetDarkMode(hwnd, isDarkMode: true);
三、没错我又封装了一个即开即用的类

在Demo中查看封装好的类:WindowEffectTest/FluentPopup.cs at master · TwilightLemon/WindowEffectTest (github.com)

使用FluentPopup
<local:FluentPopup x:Name="testPopup"
                   StaysOpen="False"
                   Placement="Mouse"
                   Background="{DynamicResource PopupWindowBackground}">
    <Grid Height="120" Width="180">
        <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center">
            nihao 
        </TextBlock>
    </Grid>
</local:FluentPopup>

其中,Background属性是我自定义的一个依赖属性,只允许使用SolidColorBrush,用于设置亚克力特效的CompositionColor。
如果你希望像Demo中那样让Popup失焦自动关闭,可以设置StaysOpenFalse,在后台打开Popup时使用:

private async void ShowPopupBtn_Click(object sender, RoutedEventArgs e)
{
    await Task.Yield();
    testPopup.IsOpen = true;
}

关于为什么要加上await Task.Tield(),可以看看吕毅大佬的文章:一点点从坑里爬出来:如何正确打开 WPF 里的 Popup? - Walterlv

在全局内使用FluentToolTip

我自定义了一个附加属性FluentTooltip.UseFluentStyle,你只需要在App.xaml中设置即可全局生效: 这里的PopupWindowBackgroundForeColor是我自定义的颜色资源,你可以根据自己的需要来设置。
同样地Background仅支持SolidColorBrush

<Style TargetType="{x:Type ToolTip}">
    <Setter Property="local:FluentTooltip.UseFluentStyle"
            Value="True" />
    <Setter Property="Background"
            Value="{DynamicResource PopupWindowBackground}" />
    <Setter Property="Foreground"
            Value="{DynamicResource ForeColor}" />
</Style>

这样你就可以方便地创建一个Fluent风格的ToolTip了:

<Button 
    ToolTip="xxxxx"
    />
参考链接

友链

:::note[AppBar] 在吕毅大佬的文章中已经详细介绍了什么是:AppBar: WPF 使用 AppBar 将窗口停靠在桌面上,让其他程序不占用此窗口的空间(附我封装的附加属性) - walterlv
即让窗口固定在屏幕某一边,并且保证其他窗口最大化后不会覆盖AppBar占据的区域(类似于Windows任务栏)。
:::

但是在我的环境中测试时,上面的代码出现了一些问题,例如非100%缩放显示时的坐标计算异常、多窗口同时停靠时布局错乱等。所以我重写了AppBar在WPF上的实现,效果如图:

一、AppBar的主要申请流程

主要流程如图:

(图注:ABN_POSCHANGED消息在任何需要调整位置之时都会触发,括号只是举个例子)

核心代码其实在于如何计算停靠窗口的位置,要点是处理好一下几个方面:

  1. 修改停靠位置时用原窗口的大小计算,被动告知需要调整位置时用即时大小计算
  2. 像素单位与WPF单位之间的转换
  3. 小心Windows的位置建议,并排停靠时会得到负值高宽,需要手动适配对齐方式
  4. 有新的AppBar加入时,窗口会被系统强制移动到工作区(WorkArea),这点我还没能找到解决方案,只能把移动窗口的命令通过Dispatcher延迟操作
二、如何使用
  1. 下载我封装好的库:AppBarTest/AppBarCreator.cs at master · TwilightLemon/AppBarTest (github.com) ::github{repo="TwilightLemon/AppBarTest"}
  2. 在xaml中直接设置:
<Window ...>

<local:AppBarCreator.AppBar>
    <local:AppBar x:Name="appBar" Location="Top" OnFullScreenStateChanged="AppBar_OnFullScreenStateChanged"/>
</local:AppBarCreator.AppBar>

...
</Window>

或者在后台创建:

private readonly AppBar appBar=new AppBar();

...Window_Loaded...
appBar.Location = AppBarLocation.Top;
appBar.OnFullScreenStateChanged += AppBar_OnFullScreenStateChanged;
AppBarCreator.SetAppBar(this, appBar);
  1. 另外你可能注意到了,这里有一个OnFullScreenStateChanged事件:该事件由AppBarMsg注册,在有窗口进入或退出全屏时触发,参数bool为true指示进入全屏。 你需要手动在事件中设置全屏模式下的行为,例如在全屏时隐藏AppBar
    private void AppBar_OnFullScreenStateChanged(object sender, bool e)
    {
        Debug.WriteLine("Full Screen State: "+e);
        Visibility = e ? Visibility.Collapsed : Visibility.Visible;
    }

我在官方的Flag上加了一个RegisterOnly,即只注册AppBarMsg而不真的停靠窗口,可以此用来作全屏模式监听。
4. 如果你需要在每个虚拟桌面都显示AppBar(像任务栏那样),可以尝试为窗口使用SetWindowLong添加WS_EX_TOOLWINDOW标签(自行查找)

三、已知问题
  1. 在我的github上的实例程序中,如果你将两个同进程的窗口并排叠放的话,会导致explorer和你的进程双双爆栈,windows似乎不能很好地处理这两个并排放置的窗口,一直在左右调整位置,疯狂发送ABN_POSCHANGED消息。(快去clone试试,死机了不要打我) 但是并排放置示例窗口和OneNote的Dock窗口就没有问题。

  2. 计算停靠窗口时,如果选择停靠位置为Bottom,则系统建议的bottom位置值会比实际的高,测试发现是任务栏窗口占据了部分空间,应该是预留给平板模式的更大图标任务栏(猜测,很不合理的设计) 自动隐藏任务栏就没有这个问题:

  3. 没有实现自动隐藏AppBar,故没有处理与之相关的WM_ACTIVATE等消息,有需要的可以参考官方文档。(嘻 我懒)

参考文档:

ABM_QUERYPOS message (Shellapi.h) - Win32 apps | Microsoft Learn ABM_NEW & ABM_SETPOS etc..

友链

先看一下最终效果,左图为使用亚克力材质并添加组合颜色的效果;右图为MicaAlt材质的效果。两者都自定义了标题栏并且最大限度地保留了DWM提供的原生窗口效果(最大化最小化、关闭出现的动画、窗口阴影、拖拽布局器等)。接下来把各部分的实现一个个拆开来讲讲。

一、使用窗口材质特效

先粗略介绍一下目前win11和win10上的材质特效类型及一些特性:

  • [Windows10 1903 ~ Windows11 lastest] :Acrylic 亚克力材质 | 支持使用组合颜色 | 窗口失去焦点时不失效、有拖动窗口延迟 | API: SetWindowCompositionAttribute

1803版本开放此API 但是对Win32应用支持不好

  • Windows11: API: SetWindowAttribute 其实现的材质特性:原生不支持组合颜色 对于非WindowStyle.None的窗口失焦失效 无拖动窗口延迟 提供了暗亮模式

    • Acrylic 亚克力材质 动态模糊,背景取决于窗口下方的内容
    • Mica 云母材质 背景仅取桌面壁纸(第三方动态壁纸软件无效) 颜色变化较为平缓 win11系统应用的窗口背景大部分使用此材质
    • MicaAlt 同Mica材质,区别是此材质的颜色变化更突出 文件管理器的标题栏使用此材质

在win11上同样支持使用win10的SetWindowCompositionAttribute启用旧版API,只不过需要不同的使用条件

前面的文章中介绍了在win11上启用材质的方法,但有不少弊端。之后诺尔大佬探究出从底层满足调用条件的方法,总结如下:

  1. 无论调用哪个API,都需要设置AllowTransparency="True",弊端是带来性能问题和鼠标穿透(即使DWM已经绘制了底层颜色),改为调用:
var hwndSource = (HwndSource)PresentationSource.FromVisual(_window);
hwndSource.CompositionTarget.BackgroundColor = Colors.Transparent;
  1. WindowChrome.GlassFrameThickness 对于SetWindowCompositionAttribute需要值为-1,另一者则需要为0,弊端是可能我们并不需要WindowsChrome来拓展整个客户区,改为调用DwmExtendFrameIntoClientArea,详细见后文示例程序。

如果你想动手实现一下,可以参照:[.NET,WPF] 窗体云母, 亚克力, 透明, 混合颜色, 模糊背景, 亮暗色主题全讲 (slimenull.com)

下面给出我封装好的附加属性:

使用方法很简单,在你的xaml中添加以下作为Window的子标签,并且将Window.Background设为Transparent.

<local:WindowMaterial.Material>
        <local:WindowMaterial x:Name="windowMaterial" 
                              IsDarkMode="True" 
                              UseWindowComposition="False" 
                              MaterialMode="MicaAlt" 
                              CompositonColor="#CC6699FF" >
        </local:WindowMaterial>
</local:WindowMaterial.Material>

属性解释:

  • IsDarkMode: 暗色模式,主要对Mica(Alt)材质生效
  • UseWindowComposition: 在win10上无效,指示使用SetWindowCompositionAttribute,在win11上启用旧版的亚克力特效,一般用于创建无边框窗口的背景材质,此属性为True时会忽略MaterialMode
  • MaterialMode: 指示使用的材质类型 None / Acrylic / Mica / MicaAlt
  • CompositionColor: 指示使用混合颜色的值,仅对MaterialMode=Acrylic(直接设置Window.Background) 和 使用SetWindowCompositionAttribute时有效

幸运的话可以得到以下效果: 如果尝试使用亚克力材质并设置混合色的话,无论使用哪个API都会得到类似的效果: 区别在于:如果使用SetWindowAttribute提供的亚克力材质,可以调整IsDarkMode来配置背景色,但是效果不是很好。

使用附加的WindowChromeEx来将客户区拓展至标题栏

如果WindowChrome直接附加在窗口上会覆盖掉我们设置的GlassFrameThickness,故这里的设计是将WindowChrome附加在WindowMaterial上进行管理。你可以接着使用熟悉的WindowChrome提供的属性,然后把它作为资源提供给WindowMaterial.WindowChromeEx属性。

<Window.Resources>
        <WindowChrome x:Key="windowChrome" ResizeBorderThickness="8"/>
    </Window.Resources>
    
<local:WindowMaterial.Material>
    <local:WindowMaterial x:Name="windowMaterial" 
                          IsDarkMode="True" 
                          UseWindowComposition="False"
                          WindowChromeEx="{StaticResource windowChrome}"
                          MaterialMode="Acrylic" 
                          CompositonColor="#CC6699FF" >
    </local:WindowMaterial>
</local:WindowMaterial.Material>

然后就能得到: MicaAlt (DarkMode) 以及 使用 WindowComposition 的亚克力材质效果:

二、自定义标题栏并保留DWM动画

简单地介绍以下我的实现: 在Windows所以窗口创建底层都是走的WinAPI,WPF也不例外。可以通过仅提供WS_CAPTION标签来创建一个没有三大按钮的标题栏:

[DllImport("user32.dll", EntryPoint = "SetWindowLong")]
private static extern int SetWindowLong32(HandleRef hWnd, int nIndex, int dwNewLong);

[DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
private static extern IntPtr SetWindowLongPtr64(HandleRef hWnd, int nIndex, IntPtr dwNewLong);

public const int GWL_STYLE = -16;
public const long WS_CAPTION = 0x00C00000L;

public static IntPtr SetWindowLongPtr(HandleRef hWnd, int nIndex, IntPtr dwNewLong)
            => IntPtr.Size == 8
            ? SetWindowLongPtr64(hWnd, nIndex, dwNewLong)
            : new IntPtr(SetWindowLong32(hWnd, nIndex, dwNewLong.ToInt32()));

public static void EnableDwmAnimation(Window w)
{
    var myHWND = new WindowInteropHelper(w).Handle;
    IntPtr myStyle = new(WS_CAPTION);
    SetWindowLongPtr(new HandleRef(null, myHWND), GWL_STYLE, myStyle);
}

然后按照上述的方法添加WindowChrome让客户区覆盖标题栏即可。 (这里提前绘制好了自定义的标题栏样式,你需要自行处理暗色模式的变化等等)

这样WindowStyle就会失效,并且在实现WindowStyle.None的效果同时带上WS_CAPTION标签让DWM认为这是一个“原生”标题栏窗口。经过测试,另外还需加上WS_THICKFRAME|WS_MAXIMIZEBOX|WS_MINIMIZEBOX 等标签让窗口行为更贴近原生。(添加WS_THICKFRAME在移动窗口时出现系统的窗口布局器)

同样失效的还有ResizeMode.NoResize,如果你需要固定窗口大小,目前暂行的方法是设置WindowsChromeResizeBorderThickness="0",如果按照WinAPI的方法加上WS_SYSMENU则会同时带回原生标题栏的三大按钮。(为什么微软对它的解释是标题栏菜单..?)

使用我封装好的附加属性:

在Window标签中添加:(同样地这会使WindowStyleResizeMode失效)

local:DwmAnimation.EnableDwmAnimation="True"
三、添加更多窗口行为

以下Demo使用了诺尔大佬的WPF Suite来帮助简化流程。

Demo 1. 创建一个失焦不失效的亚克力材质圆角窗口 (Win11),所有设置如下:

关键部分在ws:WindowOption.Corner="Round"这一句,始终使用圆角窗口,并且拥有原生边框阴影 (右图为对照,无阴影)

手动实现参照官方文档:Apply rounded corners in desktop apps - Windows apps | Microsoft Learn

Demo 2. 添加win11的布局器

在Button中添加 ws:WindowOption.IsMaximumButton="True" 以在鼠标悬停时显示布局器

本文的所有效果均可通过诺尔大佬的WPF Suite快速实现,大家快去用呀!

最后附上测试项目地址:

::github{repo="TwilightLemon/WindowEffectTest"}

参考文章:

......

友链

当你使用Edge等浏览器或系统软件播放媒体时,Windows控制中心就会出现相应的媒体信息以及控制播放的功能,如图。
:::note[WHAT IS SMTC] SMTC (SystemMediaTransportControls) 是一个Windows App SDK (旧为UWP) 中提供的一个API,用于与系统媒体交互。接入SMTC的好处在于,将媒体控制和媒体信息共享给系统,使用通用的特性(例如接受键盘快捷键的播放暂停、接受蓝牙设备的控制),便于与其它支持SMTC的应用交互等。 ::: 在UWP App中使用它很简单,只需要调用SystemMediaTransportControls.GetForCurrentView()方法即可,但是该方法仅限在有效的UWP App中调用,否则将抛出“Invalid window handle”异常。实际上,在官方文档中提到所有XXXForCurrentView方法均不适用于UWP App以外的程序调用。

这些XxxForCurrentView方法对ApplicationView类型具有隐式依赖关系,桌面应用不支持该类型。由于桌面应用不支持 ApplicationView,因此也不支持任何XxxForCurrentView方法。

此外官方文档还给出一个可替代的接口ISystemMediaTransportControlsInterop,然而这个接口在给的SDK中有保护性,无法访问。

至此,直接创建SMTC的方法走不通。但是我发现一个奇怪的地方,UWP提供的在Windows.Media.Playback命名空间下的MediaPlayer可以和SMTC自动集成,并且可以通过SystemMediaTransportControls属性直接拿到SMTC对象。MediaPlayer内部通过某种COM组件直接创建了该NativeObject,而没有走API提供的GetForCurrentViewFromAbi方法。也就是说,SMTC组件其实不需要使用合法的UWP Window句柄来创建,只不过可能为了某些特性而加上了该限制(后文将提到)。幸运的是,MediaPlayer帮我们绕过了这点。

下文讲解手动与SMTC交互而不是直接使用MediaPlayer进行播放,你的项目可能已经有了其它的解码器(如WPF版本的MediaPlayer、Bass.Net解码器、NAudio等),则只需要将交互部分接入SMTC而不更换解码器。

文末提供了我封装好的SMTCCreatorSMTCListener,可以直接使用。

一、引用WinRT API到项目

最便捷的方法是直接修改目标框架到win10,这样就能自动引入WinRT API:

<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>

或者一些其他的方法,可以参考这篇博客:如何在WPF中调用Windows 10/11 API(UWP/WinRT) - zhaotianff - 博客园 (cnblogs.com)

二、通过MediaPlayer获取SMTC对象
using Windows.Media;
using Windows.Storage.Streams;
...
private readonly Windows.Media.Playback.MediaPlayer _player = new();
private readonly SystemMediaTransportControls _smtc;
...
//先禁用系统播放器的命令
_player.CommandManager.IsEnabled = false;
//直接创建SystemMediaTransportControls对象被平台限制,神奇的是MediaPlayer对象可以创建该NativeObject
_smtc = _player.SystemMediaTransportControls;
//启用smtc以进行自定义
_smtc.IsEnabled = true;

拿到SMTC对象之后的操作与UWP中无异,这里简单看一下:

1.设置可交互性
_smtc.IsPlayEnabled = true;
_smtc.IsPauseEnabled = true;
_smtc.IsNextEnabled = true;
_smtc.IsPreviousEnabled = true;
2.设置媒体信息
var updater = _smtc.DisplayUpdater;
updater.AppMediaId = "xxx"; //用于区分不同来源的媒体
updater.Type = MediaPlaybackType.Music; //必须指定媒体类型否则抛异常
updater.MusicProperties.Title = “Title”;//标题
/*...省略相同的字段设置...*/
updater.Thumbnail = RandomAccessStreamReference.CreateFromUri(new Uri(ImgUrl));//设置封面图
updater.Update();//最后调用以生效

播放状态需要单独设置:

_smtc.PlaybackStatus = MediaPlaybackStatus.Playing; //Paused \ Stopped
//直接设置无需更新
3.响应SMTC交互
_smtc.ButtonPressed += _smtc_ButtonPressed;
...
 private void _smtc_ButtonPressed(SystemMediaTransportControls sender, SystemMediaTransportControlsButtonPressedEventArgs args)
        {
            switch(args.Button)
            {
                case SystemMediaTransportControlsButton.Play:
                    //Play
                    break;
                case SystemMediaTransportControlsButton.Pause:
                    //Pause
                    break;
                case SystemMediaTransportControlsButton.Next:
                    //Next
                    break;
                case SystemMediaTransportControlsButton.Previous:
                    //Previous
                    break;
            }
        }

注意,文中所有SMTC的事件均由系统触发,意味着非同一线程,需要用Dispatcher来操作UI

三、获取和控制系统媒体

好消息是,负责这部分的模块GlobalSystemMediaTransportControlsSession公开可以任意使用,不受UWP平台限制。

1.获取媒体信息
var gsmtcsm = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();//获取SMTC会话管理器
gsmtcsm.CurrentSessionChanged += xxx; //当前会话改变或退出时发生,微软对CurrentSession的解释是用户可能最希望控制的媒体会话,实测为Windows控制中心顶部显示的媒体,当有多个媒体时用户可以在此选择切换
...
var session = gsmtcsm.GetCurrentSession();
if(session == null)
    return; //当前没有注册的SMTC会话

//接下来操作session即可,下面仅提供参考信息

//媒体信息改变时发生,奇怪的是这些事件提供的参数e并没有任何信息
session.MediaPropertiesChanged += async (sender, e)=>{
    //触发事件时主动拉取信息
    var info = await _globalSMTCSession.TryGetMediaPropertiesAsync();
};
//播放状态改变时发生
session.PlaybackInfoChanged +=(sender,e)=>{
    var status = globalSMTCSession.GetPlaybackInfo().PlaybackStatus;
};
2.控制媒体播放

直接调用即可

await session.TryPauseAsync();
await session.TryPlayAsync();
await session.TrySkipPreviousAsync();
await session.TrySkipNextAsync();
四、一些奇怪的地方
  1. 无法显示媒体来源,并且不会清空上一个来源的信息 可能是因为没有提供合法的UWP句柄,Windows虽然能确定是哪个exe调用的SMTC,但是拒绝直接显示exe的信息。逻辑上来说这个来源信息会被空覆盖掉,但是并没有。
  1. 信息更新不一致和延时

系统显示的会话以及提供GlobalSMTCSessionMng.获取的信息有时会不一致,二者都有可能和应用真实在播放的不一致,后者获取的封面图有时也会不一致。此外,MusicProperty的更新有时并不会实时反馈到GlobalSMTCSessionChanged事件,我测试的时候当系统内存爆满(98% 我开了一堆浏览器标签页和4个vs)的时候,更新丢失的概率在70%左右,而资源充足时可以做到几乎即时更新。

  1. 暂未实现点击跳转到App 正统UWP App的SMTC会话是可以点击跳转到App播放界面的,但是我并没有找到相关的事件。

  2. 奇怪的MediaId Windows系统似乎通过这个来区分不同的媒体来源(明明可以获得调用者- -),神奇的是如果你为两个应用设置了同样的MediaId,那么只有两个都关闭时,SMTC会话才会释放。此外通过GlobalSMTCSession.SourceAppUserModelId并不能获得你设置的MediaId,而是调用者的文件名"xxx.exe"。

五、使用我封装的库

Demo和库已经开源:
::github{repo="TwilightLemon/MediaTest"}

简单地将现有的解码器接入SMTC:

SMTCCreator? _smtcCreator = null;
...
 _smtcCreator ??= new SMTCCreator("MediaTest");
//修改播放状态
_smtcCreator.SetMediaStatus(SMTCMediaStatus.Playing);
//设置媒体信息
_smtcCreator.Info.SetAlbumTitle("AlbumTitle")
                    .SetArtist("Taylor Swift")
                    .SetTitle("Dancing With Our Hands Tied")
                    .SetThumbnail("https://y.qq.com/music/photo_new/T002R300x300M000003OK4yP2MBOip_1.jpg?max_age=2592000")
                    .Update();
//注册交互响应
_smtcCreator.PlayOrPause += _smtcCreator_PlayOrPause;
_smtcCreator.Previous += _smtcCreator_Previous;
_smtcCreator.Next += _smtcCreator_Next;//合适的时候调用释放资源_smtcCreator.Dispose();

简单地控制系统媒体:

SMTCListener _smtcListener = null;
...
_smtcListener = await SMTCListener.CreateInstance();
_smtcListener.MediaPropertiesChanged += _smtcListener_MediaPropertiesChanged;
_smtcListener.PlaybackInfoChanged += _smtcListener_PlaybackInfoChanged;
_smtcListener.SessionExited += _smtcListener_SessionExited;
...
//媒体退出
 private void _smtcListener_SessionExited(object? sender, EventArgs e) { }

//播放状态改变
private void _smtcListener_PlaybackInfoChanged(object? sender, EventArgs e)
{
    Dispatcher.Invoke(() =>
    {
        var info = _smtcListener.GetPlaybackStatus();
        if (info == null) return;
        StatusTb.Text = info.ToString();
    });
}
//媒体信息改变
private void _smtcListener_MediaPropertiesChanged(object? sender, EventArgs e)
{
    Dispatcher.Invoke(async () =>
    {
        var info = await _smtcListener.GetMediaInfoAsync();
        if (info == null) return;
        TitleTb.Text = info.Title;
        ArtistTb.Text = info.Artist;
        AlbumTitleTb.Text = info.AlbumTitle;
        //获取封面图的方法
        if (info.Thumbnail != null)
        {
            var img = new BitmapImage();
            img.BeginInit();
            img.StreamSource = (await info.Thumbnail.OpenReadAsync()).AsStream();
            img.EndInit();
            ThumbnailImg.Source = img;
        }
    });
}
...
//控制播放
await _smtcListener.Previous();
await _smtcListener.Next();
await _smtcListener.Pause();
await _smtcListener.Play();
六、写在最后

参考资料:

友链

看了看原生UWP的ScrollViewer,滑动很流畅(例如 开始菜单),但是WPF自带的ScrollViewer滚动十分生硬..

突发奇想,今天来实现一个流畅滚动的ScrollViewer.

一、目标

查阅网上的实现方法,要么直接重写控件,要么一堆Storyboard..很是无奈,还有些许bug.

主要目标如下:

1.能够流畅地滚动ScrollViewer 要求:有惯性的物理滚动 而不是 生硬的 内容滚动.

2.在使用动画的过程中,避免多个begin导致卡顿

3.好用(? )

二、准备工作

实现以上目标需要到的几件东西:

1.对于Listbox之类的控件需要先将滚动方式变为 像素滚动 (若没有的话就可以不设置)

<ListBox
    VirtualizingPanel.ScrollUnit = "Pixel" 
    VirtualizingPanel.VirtualizationMode="Recycling"/>

2.选一种动画使用的 缓动函数 这里推荐使用 CubicEase的EaseOut函数 (优点:接近于惯性滚动 对于CPU比较友好)

3.要对ScrollViewer启用滚动动画 需要自己写附加属性:

public static class ScrollViewerBehavior
{
      public static readonly DependencyProperty VerticalOffsetProperty = DependencyProperty.RegisterAttached("VerticalOffset", typeof(double), typeof(ScrollViewerBehavior), new UIPropertyMetadata(0.0, OnVerticalOffsetChanged));
      public static void SetVerticalOffset(FrameworkElement target, double value) => target.SetValue(VerticalOffsetProperty, value);
      public static double GetVerticalOffset(FrameworkElement target) => (double)target.GetValue(VerticalOffsetProperty);
      private static void OnVerticalOffsetChanged(DependencyObject target, DependencyPropertyChangedEventArgs e) => (target as ScrollViewer)?.ScrollToVerticalOffset((double)e.NewValue);
}
三、编码
//继承ScrollViewer 通过截获MouseWheel事件控制滚动
    public class MyScrollViewer : ScrollViewer
    {
        //记录上一次的滚动位置
        private double LastLocation = 0;
        //重写鼠标滚动事件
        protected override void OnMouseWheel(MouseWheelEventArgs e)
        {
            double WheelChange = e.Delta;
            //可以更改一次滚动的距离倍数 (WheelChange可能为正负数!)
            double newOffset = LastLocation - (WheelChange * 2);
            //Animation并不会改变真正的VerticalOffset(只是它的依赖属性) 所以将VOffset设置到上一次的滚动位置 (相当于衔接上一个动画)
            ScrollToVerticalOffset(LastLocation);
            //碰到底部和顶部时的处理
            if (newOffset < 0)
                newOffset = 0;
            if (newOffset > ScrollableHeight)
                newOffset = ScrollableHeight;

            AnimateScroll(newOffset);
            LastLocation = newOffset;
            //告诉ScrollViewer我们已经完成了滚动
            e.Handled = true;
        }
        private void AnimateScroll(double ToValue)
        {
            //为了避免重复,先结束掉上一个动画
            BeginAnimation(ScrollViewerBehavior.VerticalOffsetProperty, null);
            DoubleAnimation Animation = new DoubleAnimation();
            Animation.EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseOut };
            Animation.From = VerticalOffset;
            Animation.To = ToValue;
            //动画速度
            Animation.Duration = TimeSpan.FromMilliseconds(800);
            //考虑到性能,可以降低动画帧数
            //Timeline.SetDesiredFrameRate(Animation, 40);
            BeginAnimation(ScrollViewerBehavior.VerticalOffsetProperty, Animation);
        }
    }

使用方法:直接创建 <MyScrollViewer/> 如果是在ListBox中,可以通过修改模板的方式,把<ScrollViewer/>换成MyScrollViewer即可.

四、关于性能

按照上述方法实现的功能,CPU占用率基本稳定在10% ,但是要疯狂地上下滑动滚轮的话,25%+ 但是动画还是很流畅的

以下有几点可以改进的措施:

滚动的CPU占用不只是对偏移量的一个计算,与你滚动的UI画面内容具有很大的关系

例如大量的图片Image 3D对象 复杂的自定义控件 等等 都需要在滚动时计算并绘制。

你可以尝试以下方法:

  1. 启用ListBox的虚拟化技术

  2. 对于图像的绘制,可以通过降低其渲染度(可能会损坏图片质量 特别是低清小图 ),对你的图片对象使用:

    RenderOptions.SetBitmapScalingMode(控件对象,BitmapScalingMode.LowQuality);
    
  3. 就像上文中所说,可以通过降低渲染帧数来控制CPU占用率(可能会有失流畅度)

  4. 如果能找到更好的缓动函数

友链

WPF 项目迁移到.Net Core中时居然出了一堆问题...(很无语)

今天在使用的时候居然发现Process.Start居然打不开Url链接了? 报 找不到指定文件 的异常?!

一、bug重现

首先以.Net Core 3.1框架 中一个Console项目 打开百度为例:
运行然后你就会得到:

纳闷的是,这种方法打开Url在.Net Framework中是没问题的..

二、解决方法 方案一:使用windows系统自带的资源管理器来打开Url
Process.Start("explorer", "https://www.baidu.com");

你就可以得到一个浏览器窗口,打开着百度网页..
但是如果你的Url复杂一点的话,例如打开百度搜索what

Process.Start("explorer", "https://www.baidu.com/s?wd=what");
explorer表示不干,并直接给你抛了个文件管理器窗口

所以这个方法只可以用于打开简单的URL...

方案二:使用cmd中的start命令 可以打开任意形式的URL
string url = "https://www.baidu.com/s?wd=what";
Process p = new Process();
p.StartInfo.FileName = "cmd.exe";
p.StartInfo.UseShellExecute = false;    //不使用shell启动
p.StartInfo.RedirectStandardInput = true;//喊cmd接受标准输入
p.StartInfo.RedirectStandardOutput = false;//不想听cmd讲话所以不要他输出
p.StartInfo.RedirectStandardError = true;//重定向标准错误输出
p.StartInfo.CreateNoWindow = true;//不显示窗口
p.Start();

//向cmd窗口发送输入信息 后面的&exit告诉cmd运行好之后就退出
p.StandardInput.WriteLine("start "+url + "&exit");
p.StandardInput.AutoFlush = true;
p.WaitForExit();//等待程序执行完退出进程
p.Close();

OK

以上的问题我只在Windows平台上找到过,不知Linux和OSX有没有...

Thanks

友链

在C#中有许多音频播放的方案,例如WinForm里调用系统自带MediaPlayer的COM组件和WPF的MediaPlayer(实质上还是WindowsMediaPlayer)
以及一堆API播放和DirectX (SDK一大堆)
于是我找到了适用于全平台、高效、小巧的音频解码器--Bass (主程序基于C++ C#可通过官方库Bass.Net调用)

一、开始

首先需要到官网下载bass.dll 主程序文件(大约 257kb): http://www.un4seen.com/

以及类库(.Net平台调用) : 你可以在 http://www.bass.radio42.com/bass_register.html 中使用你的邮箱即可注册到一个key和下载Bass.Net.dll(大约520kb)

官方文档: http://www.bass.radio42.com/help/

P.S: bass.dll需要放在程序主目录下 Bass.Net.dll随意(添加到程序集引用)

二、编码

在一切开始之前,你需要先注册程序和初始化Bass解码器:

using Un4seen.Bass;//添加引用
...
BassNet.Registration("你的邮箱", "你注册到的Key");
Bass.BASS_Init(-1, 44100, BASSInit.BASS_DEVICE_CPSPEAKERS, /*窗口句柄 没有的话就IntPtr.Zero*/);
1. 实现简单的mp3播放

播放mp3有2种方式 :从文件中加载、从URL中加载

例1: 从文件中加载:
//---------调用到的方法------------
public static int BASS_StreamCreateFile(
    string file,//文件路径
    long offset,//偏移量,一般不怎么使用
    long length,//如果你使用了偏移量,定义一个偏移量之后的读取长度
    BASSFlag flags//以什么方式创建流
)
//----------------------------------

帮助链接和其他信息: http://www.bass.radio42.com/help/html/e49e005c-52c0-fc33-e5f9-f27f2e0b1c1f.htm

//创建流的id,建议作为全局变量加入(如果是播放单文件)
private int stream =  -1024;//可以自己定义一个初始值
...
//从文件中创建一个简单的FLOAT音频流,返回流的id 便于控制和查询
stream = Bass.BASS_StreamCreateFile(你的文件完整路径, 0L, 0L, BASSFlag.BASS_SAMPLE_FLOAT);
... 开始播放
//参数:int 要播放的流的id,bool 是否在播放完成之后再重新播放
Bass.BASS_ChannelPlay(stream, false);
... 暂停播放
Bass.BASS_ChannelPause(stream);
例2:从URL中加载:
stream = Bass.BASS_StreamCreateURL(url, 0, BASSFlag.BASS_SAMPLE_FLOAT, null, IntPrt.Zero);

只是加载音频流的方式改变了,其他的一致
如果你需要一些其他的功能(请求Url时的标头和下载回调),请参阅以下:

1.添加URL请求标头:

很简单: 在url参数里添加即可,url与每一条标头之间用"\r\n"换行,例如:

stream = Bass.BASS_StreamCreateURL(url+"\r\n"+"一条标头,Header:Content"+"\r\n"+"再一条标头",...);
2.下载回调: 多用于缓存
//必须是全局变量,否则会被GC回收!
private DOWNLOADPROC _myDownloadProc;
private FileStream _fs = null;//写入文件的流
private byte[] _data; // 本地缓存

...
//添加调用
 _myDownloadProc = new DOWNLOADPROC(DownloadCallBack);
...

//下载回调,由Bass调用
private void DownloadCallBack(IntPtr buffer, int length, IntPtr user)
        {
            // file length
            long len = Bass.BASS_StreamGetFilePosition(stream, BASSStreamFilePosition.BASS_FILEPOS_END);
            // download progress
            long down = Bass.BASS_StreamGetFilePosition(stream, BASSStreamFilePosition.BASS_FILEPOS_DOWNLOAD);
            //可在此处添加下载进度的Callback

            if (_fs == null)
            {
                // 开始下载,打开文件流
               //坑:当你切歌的时候,Bass并不会继续下载而且会向你发送已经下载完成的标识,此时你得到的文件是不完整的!所以此处先作为.cache下载
                _fs = File.OpenWrite(保存的路径 + ".cache");
            }
            if (buffer == IntPtr.Zero)
            {
                // 下载完成
                _fs.Flush();
                _fs.Close();
                _fs = null;
                FileInfo fi = new FileInfo(DLPath + ".cache");
               //如果下载不完整的话就删除.cache
                if (fi.Length != len)
                {
                    fi.Delete();
                }
                else
                {
                    //如果下载完整的话就移动到缓存(下载)目录
                    fi.MoveTo(你的路径, true);
                    //这里可以做下载完成的回调
                }
            }
            else
            {
                //接受到下载数据,实质上是Bass传来数据的指针,C#根据指针从内存中复制数据
                // increase the data buffer as needed
                if (_data == null || _data.Length < length)
                    _data = new byte[length];
                // copy from managed to unmanaged memory
                Marshal.Copy(buffer, _data, 0, length);
                // write to file
                _fs.Write(_data, 0, length);
            }
        }
2. 获得和设置一些常规数据(播放进度、声音): 1. 设置、获取音量:

坑:音量的设置是暂时性的,仅对于你输入的stream,当你再次新建音频流时(例如切歌),音量会恢复默认!你可能需要记录下设置的音量并在下一次加载流时将音量设置Set到stream中!

//设置音量 值在0~1之间 默认值为1
Bass.BASS_ChannelSetAttribute(stream, BASSAttribute.BASS_ATTRIB_VOL, value);

...

//获取当前音量 ref value
float value=0;
Bass.BASS_ChannelGetAttribute(stream, BASSAttribute.BASS_ATTRIB_VOL,ref value);
2.播放进度和总长(时间):
 //长度
public TimeSpan GetLength
        {
            get
            {
                double seconds = Bass.BASS_ChannelBytes2Seconds(stream, Bass.BASS_ChannelGetLength(stream));
                return TimeSpan.FromSeconds(seconds);
            }
        }
//当前播放位置
public TimeSpan Position
        {
            get
            {
                double seconds = Bass.BASS_ChannelBytes2Seconds(stream, Bass.BASS_ChannelGetPosition(stream));
                return TimeSpan.FromSeconds(seconds);
            }
            set => Bass.BASS_ChannelSetPosition(stream, value.TotalSeconds);
        }
3.获取FFT数据,你可以用这个数据来做频谱:
//获取256位的FFT数据,当然你可以选择更大的,但是也足够了
//坑:暂停播放时获得的FFT数据仍是没有暂停前的数据(停留在此位置的FFT)如果你需要做频谱图,在暂停时应该手动设置为0,因为Bass并不会在暂停时返回0
float[] fft = new float[256];
Bass.BASS_ChannelGetData(stream, fft, (int)BASSData.BASS_DATA_FFT256);

Bass并没有提供播放完成的回调,你需要设置一个Timer来判断是否播放完成,理论上当当前位置=播放总长时算播放完成...

3. 更新设备和结束时

系统会将当前的默认设备放在集合的[0]位,但是Bass并不会自动更新设备也没有更新设备的事件回调(或是我没有找到?),所以你需要自己检查下有没有新的设备插入,你需要手动更新设备(如果需要):

public void UpdataDevice()
        {
            var data = Bass.BASS_GetDeviceInfos();
            int index = -1;
            for (int i = 0; i < data.Length; i++)
                if (data[i].IsDefault)
                {
                    index = i;
                    break;
                }
            if (!data[index].IsInitialized)
                Bass.BASS_Init(index, 44100, BASSInit.BASS_DEVICE_CPSPEAKERS, wind);
            var a = Bass.BASS_ChannelGetDevice(stream);
            if (a != index)
            {
                Bass.BASS_ChannelSetDevice(stream, index);
                Bass.BASS_SetDevice(index);
            }
        }

结束时,需要释放流和Bass解码器:

Bass.BASS_ChannelStop(stream);
Bass.BASS_StreamFree(stream);
Bass.BASS_Stop();
Bass.BASS_Free();
4.倍速播放和其他FX效果

为什么这里需要重新起个标题呢?
这个方法推翻了之前用到的stream创建形式,但是控制播放暂停等方法不变。
首先你需要引入几个Bass的概念:

  • 音频流:stream,用于播放音频,就是你控制播放暂停时传入的stream
  • 解码流 decode,用于解码音频,然后传给Fx效果器,Fx效果器会返回给你一个音频流

实现此方法,你需要下载FX扩展(就在bass.dll的地方下载 选择add-on 即可),大约85kb
添加using引用:

using Un4seen.Bass.AddOn.Fx;

创建流:

//全局变量,解码流
private int decode;

decode = Bass.BASS_StreamCreateFile(你的文件, 0L, 0L, BASSFlag.BASS_STREAM_DECODE);
//相对于前面的你只需要把标签换成BASS_STREAM_DECODE即可,CreateURL亦是如此

//将解码流传入Fx效果器中,你将得到一个音频流
stream = BassFx.BASS_FX_TempoCreate(decode, BASSFlag.BASS_FX_FREESOURCE );

设置Fx效果:

value值在-90~0~5000之间 以百分制计算 例如 加速/减速 播放10%那么value=10,不用倍速播放value=0 减速 value=-10

Bass.BASS_ChannelSetAttribute(stream, BASSAttribute.BASS_ATTRIB_TEMPO, value);

获取Fx效果的方法和音量一致,更换标签为BASSAttribute.BASS_ATTRIB_TEMPO即可.

更多的FX效果可以参阅: http://www.bass.radio42.com/help/html/90d034c4-b426-7f7c-4f32-28210a5e6bfb.htm 原理一样 修改标签和value值即可