Cover image 鸦居
Flutter Windows 接入系统媒体控制(SMTC)完整教程
前言
最开始想给 PiliNara 的 Windows 端加系统媒体控制时,很容易先入为主地认为:既然 Android 上 audio_service 已经能完美驱动通知栏和锁屏媒体控件,那 Windows 上只要找到对应的平台实现,加个依赖就能直接用。
但真往 audio_service 的架构和 Windows 的媒体会话机制里翻,就会发现事情没有这么简单。这个功能从”加一行依赖”到真正在 Windows 11 的媒体浮窗里看到封面和按钮,中间经历了三层完全不同的坑,每一层都足以让功能”毫无效果”或”静默失败”。
本文基于 PiliNara 的实际迭代过程,整理成一份较为完整的教程: 讲讲我是怎么在Pilinara里面接入SMTC的。也会顺便讲讲SMTC 和 audio_service 的架构,再给出标准接入步骤,顺便讲讲适配途中遇到的三个坑的根因和排查思路。
什么是 SMTC
SMTC(System Media Transport Controls)是 Windows 的系统媒体传输控制接口,基于 WinRT 的 SystemMediaTransportControls 类。它负责:
- 在任务栏音量弹出控件、Win11 媒体浮窗、Win+G 小组件里展示当前媒体信息(标题、艺术家、专辑、封面)
- 接收用户的媒体按键(播放/暂停/上下曲/快进快退)并回调给应用
- 维护媒体会话的播放状态(播放中/暂停/停止)
audio_service 的架构分层
audio_service 是一个跨平台的媒体控制抽象层。理解它的分层,是排查一切问题的前提:
┌─────────────────────────────────────────────┐│ 你的应用(定义 BaseAudioHandler) ││ play() / pause() / seek() / skipToNext() │└──────────────────┬──────────────────────────┘ │┌──────────────────▼──────────────────────────┐│ audio_service(Dart 抽象层) ││ .init / mediaItem / playbackState │└──────────────────┬──────────────────────────┘ │ AudioServicePlatform.instance┌──────────────────▼──────────────────────────┐│ 平台实现(audio_service_platform_interface ││ Android/iOS/macOS 原生 / audio_service_win │└──────────────────┬──────────────────────────┘ │┌──────────────────▼──────────────────────────┐│ 系统媒体会话(Android MediaSession / ││ Windows SMTC / macOS NowPlaying) │└─────────────────────────────────────────────┘关键点:平台实现是可替换的。audio_service 通过 AudioServicePlatform.instance 这个单例来调用平台能力,任何实现了 audio_service_platform_interface 的插件都可以替换它。
但这里有个问题就是:我们平常直接使用的Audio_service 其没有适配Linux和Windows ,audio_service_platform_interface 在 Windows 和 Linux 上默认用的是 NoOpAudioService:
static AudioServicePlatform _instance = (!kIsWeb && (Platform.isWindows || Platform.isLinux)) ? NoOpAudioService() : MethodChannelAudioService();也就是说,在 Windows 上,所有媒体控制调用都是静默空操作——不报错,也不产生任何系统媒体会话。导致Flutter应用在Win上无法使用系统媒体控制。
在之前,大家的做法有:
- 使用smtc_windows 这个插件,它是一个独立的框架,直接在Flutter里调用WinRT的SMTC API。缺点是你需要在你原有的播放器逻辑里加上它的对应的初始化和回调,和audio_service的抽象层不兼容。并且据我所知,smtc_windows 目前还不支持本地封面缩略图,只支持远程封面。
- 手动使用
flutter_rust_bridge或者dart:ffi去调用WinRT的SMTC API。缺点就是你需要自己手写C++或者Rust的代码去桥接WinRT的SMTC API,这就需要跨语言的操作了,工作量有点大。
TIP关于第二种的手动实现可以看这两篇文章Flutter 应用适配 SMTC和Flutter 应用如何支持 SMTC,讲的非常好
我这次选择的做法是 audio_service_win:一个实现了 audio_service_platform_interface 的插件,通过 WinRT 的 SMTC 把媒体会话桥接到 Windows 系统。由于Pilinana本来就是使用Audio Service进行其他平台的媒体控制的,所以如果想要接入Windows的话,直接安装这个依赖,然后初始化就可以,不必要进行其他的改动。
我自行Fork了并做了一些修改,主要是修改了封面加载优先级的问题,优先加载本地缓存的封面文件,而不是直接使用网络URL。可以见我的仓库
接下来就是详细的接入步骤。
第一步:添加依赖
在 pubspec.yaml 里添加 audio_service 和 audio_service_win:
dependencies: audio_service: ^0.18.0 audio_service_win: ^0.0.3TIP
audio_service_win是audio_service的平台实现,不是独立框架。你仍然用audio_service的 API 写业务逻辑,audio_service_win会在 Windows 上被自动注册。
audio_service_win 通过 flutter.plugin.implements: audio_service_platform_interface 声明自己是平台实现,Flutter 构建时会在 Windows 上自动调用它的 registerWith(),把 AudioServicePlatform.instance 替换成 AudioServiceWin。
第二步:定义 AudioHandler
定义一个继承 BaseAudioHandler 的类,实现媒体控制方法。这些方法会被系统媒体按钮触发:
class MyAudioHandler extends BaseAudioHandler with SeekHandler { // 播放/暂停/上下曲等回调,由你的播放器实现 Future<void>? Function()? onPlay; Future<void>? Function()? onPause; Future<void>? Function(Duration position)? onSeek; Future<void>? Function()? onSkipToNext; Future<void>? Function()? onSkipToPrevious;
@override Future<void> play() => onPlay?.call() ?? Future.syncValue(null);
@override Future<void> pause() => onPause?.call() ?? Future.syncValue(null);
@override Future<void> seek(Duration position) { playbackState.add( playbackState.value.copyWith(updatePosition: position), ); return onSeek?.call(position) ?? Future.syncValue(null); }
@override Future<void> skipToNext() => onSkipToNext?.call() ?? Future.syncValue(null);
@override Future<void> skipToPrevious() => onSkipToPrevious?.call() ?? Future.syncValue(null);}第三步:初始化
在应用启动时调用 AudioService.init:
Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final audioHandler = await AudioService.init( builder: () => MyAudioHandler(), config: const AudioServiceConfig( // 在 Windows 上被用作 SMTC 的 appid androidNotificationChannelId: 'com.example.myapp.channel.audio', androidNotificationChannelName: 'Music playback', ), ); runApp(MyApp(audioHandler: audioHandler));}WARNING这一步必须在应用启动时真正执行到。
AudioService.init内部会调用_platform.configure(),而AudioServiceWin.configure会调用原生initializeSMTC创建 SMTC 会话。如果初始化链路没走到,后面所有媒体控制都是空操作。
第四步:更新媒体信息
播放时,通过 mediaItem 和 playbackState 两个 stream 向系统广播当前媒体信息:
// 更新媒体元数据(标题、艺术家、封面)audioHandler.mediaItem.add(MediaItem( id: 'video-123', title: '视频标题', artist: 'UP主', duration: Duration(minutes: 10), artUri: Uri.parse('https://example.com/cover.jpg'),));
// 更新播放状态audioHandler.playbackState.add(PlaybackState( controls: [ MediaControl.skipToPrevious, MediaControl.play, MediaControl.pause, MediaControl.skipToNext, ], processingState: AudioProcessingState.ready, playing: true, updatePosition: Duration(seconds: 30),));audio_service 会监听这些 stream,把变化同步到平台实现,最终反映到系统媒体浮窗上。
第五步:处理媒体按钮事件
系统媒体按钮(播放/暂停/上下曲/快进快退)会通过 audio_service_win 的 MethodChannel 回调到你的 handler 对应方法:
SMTC 按钮按下 → C++ 层捕获 ButtonPressed → MethodChannel 'audio_service_win' 发 'onSMTCButtonPressed' → AudioServiceWin 的 _handlerCallbacks → 你的 handler.play() / pause() / skipToNext() / ...audio_service_win 支持这些按钮:play、pause、stop、next、previous、fastForward、rewind。
audio_service_win 的机制:Dart ↔ C++ ↔ WinRT
理解 audio_service_win 内部的数据流,对排查问题很有帮助。它分三层:
Dart 层(AudioServiceWin extends AudioServicePlatform):实现平台接口,通过 MethodChannel audio_service_win 与原生通信。核心方法:
// configure → 初始化 SMTCawait methodChannel.invokeMethod('initializeSMTC', {'appid': ...});
// setMediaItem → 更新元数据await methodChannel.invokeMethod('setMediaItem', { 'title': ..., 'artist': ..., 'album': ..., 'artUri': ...,});
// setState → 更新播放状态(0 播放 / 1 暂停 / 2 停止)await methodChannel.invokeMethod('updateState', {'state': state});C++ 层(AudioServiceWinPluginCApi):用 WinRT 的 SystemMediaTransportControls 和 DisplayUpdater 操作系统媒体会话:
auto smtc = SystemMediaTransportControls::GetForCurrentView();auto updater = smtc.DisplayUpdater();updater.Type(MediaPlaybackType::Music);updater.MusicProperties().Title(winrt::to_hstring(title));updater.MusicProperties().Artist(winrt::to_hstring(artist));updater.Update();封面处理:C++ 层对 artUri 有两种处理分支:
if (artUri 以 http:// 或 https:// 开头) { // 用 Windows 系统网络栈下载(CreateFromUri) auto thumbRef = RandomAccessStreamReference::CreateFromUri(uri); updater.Thumbnail(thumbRef);} else { // 当作本地文件路径读取(GetFileFromPathAsync) auto storageFile = StorageFile::GetFileFromPathAsync(localPath).get(); auto thumbRef = RandomAccessStreamReference::CreateFromFile(storageFile); updater.Thumbnail(thumbRef);}SMTC 的能力边界
audio_service_win 目前(0.0.3)的实现有一些限制,接入前需要了解:
| 能力 | 支持情况 |
|---|---|
| 标题/艺术家/专辑/封面 | ✅ |
| 播放/暂停/停止 | ✅ |
| 上下曲/快进快退 | ✅ |
| 播放状态(播放中/暂停) | ✅ |
| 进度条拖动(scrubber) | ❌ 未实现 position 同步 |
| 队列(setQueue) | ❌ 空实现,只显示当前一条 |
| 播放速率/随机/循环 | ❌ |
如果你的应用依赖进度条拖动或队列展示,需要自行扩展 C++ 层。
踩坑一:封面加载不出来
SMTC 面板出现了,但封面一张都加载不出来。这又是个”静默失败”——WinRT 的 API 全部调用成功,没有任何报错渠道。
先排查了封面在 audio_service 里的传递链路。这里有个值得展开的机制:audio_service 对网络 artUri 的处理,并不是把 URL 直接丢给系统,而是:
- 先发一次无封面的
setMediaItem - 用应用自己的网络栈(
_loadArtwork→ cacheManager)把封面下载到本地文件 - 把本地文件路径放进
extras['artCacheFile'],再发一次setMediaItem
Android/iOS/macOS 的原生实现都是本地文件优先——读 artCacheFile,网络 URL 只是兜底:
// Android AudioService.javaString artCacheFilePath = mediaMetadata.getString("artCacheFile");if (artCacheFilePath != null) { artBitmap = loadArtBitmap(artCacheFilePath, null); // 本地文件}// iOS/macOS AudioServicePlugin.mNSString* artCacheFilePath = extras[@"artCacheFile"];UIImage* artImage = [UIImage imageWithContentsOfFile:artCacheFilePath];但 audio_service_win 的 Dart 层只传了 artUri.toString(),把 artCacheFile 丢掉了。于是 Windows 上封面走的是 CreateFromUri——Windows 系统网络栈独立下载,不走应用的代理/证书配置。
我一度以为这就是根因,把 audio_service_win 改成优先读 artCacheFile 本地文件(和其他平台对齐):
final artCacheFile = request.mediaItem.extras?['artCacheFile'];final artUri = request.mediaItem.artUri;String? artUriString;if (artCacheFile is String && artCacheFile.isNotEmpty) { artUriString = artCacheFile; // 本地文件优先} else if (artUri != null) { artUriString = artUri.toString(); // 兜底网络 URL}改完实测,封面还是加载不出来。
踩坑二(最终根因):Windows 系统媒体面板不解码 WebP
本地文件也加载不出来,说明问题不在”谁下载”,而在格式。
B 站图床的封面 URL 在 Pilinara 经过 safeThumbnailUrl 处理后,会统一带上 @...q.webp 这样的处理参数,产出的是 WebP 变体。而 Windows 渲染媒体浮窗的 shell 进程,只认系统内置的图片格式(JPEG/PNG 等),不使用 Store 扩展提供的 WebP 编解码器。
同一套 WinRT 调用、同一张图:JPG 显示,WebP 不显示。缩略图引用在消费端(shell)解码失败,而且是静默失败——插件端 API 全部调用成功,没有任何报错渠道。
这一个根因同时解释了之前的所有现象:
- 最初
CreateFromUri(https)不显示——URL 指向的就是@...q.webp变体,不是网络问题 - 改成
artCacheFile本地文件后仍不显示——缓存文件还是 WebP,只是换了个读取方式 - Android/iOS/macOS 正常——它们的原生通知/控制中心在应用自己进程里解码,平台本身支持 WebP
修复方案很干净:B 站图床原生支持把处理参数的后缀换成 .jpg(如 xxx.jpg@10q.jpg),所以给 Windows 加个分支,把 .webp 后缀换成 .jpg,其他平台行为完全不变:
Uri getUri(String? cover) { String url = ImageUtils.safeThumbnailUrl(cover); if (Platform.isWindows && url.contains('@') && url.endsWith('.webp')) { url = '${url.substring(0, url.length - '.webp'.length)}.jpg'; } return Uri.parse(url);}这里附赠一个Ps脚本,可以在你的设备上测试 WebP 是否能在系统媒体浮窗里显示:
$ErrorActionPreference = 'Stop'Add-Type -AssemblyName System.Runtime.WindowsRuntimeAdd-Type -AssemblyName PresentationCore
$webp = 'test.webp' #这里填你本地的webp文件路径$jpg = Join-Path $env:TEMP 'smtc_test_cover.jpg'
# webp -> jpg (WIC)$fs = [IO.File]::OpenRead($webp)$dec = [Windows.Media.Imaging.BitmapDecoder]::Create($fs, 'None', 'OnLoad')$enc = New-Object Windows.Media.Imaging.JpegBitmapEncoder$enc.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($dec.Frames[0]))$out = [IO.File]::Create($jpg); $enc.Save($out); $out.Close(); $fs.Close()Write-Host "converted: $jpg"
# WinRT async helper$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]function Await($op, $t) { $m = $asTaskGeneric.MakeGenericMethod($t) $task = $m.Invoke($null, @($op)) $task.Wait(-1) | Out-Null $task.Result}
[void][Windows.Media.Playback.MediaPlayer,Windows.Media.Playback,ContentType=WindowsRuntime][void][Windows.Storage.StorageFile,Windows.Storage,ContentType=WindowsRuntime][void][Windows.Storage.Streams.RandomAccessStreamReference,Windows.Storage.Streams,ContentType=WindowsRuntime]
$mp = New-Object Windows.Media.Playback.MediaPlayer$smtc = $mp.SystemMediaTransportControls$smtc.IsPlayEnabled = $true$smtc.IsPauseEnabled = $true$smtc.IsEnabled = $true$smtc.PlaybackStatus = [Windows.Media.MediaPlaybackStatus]::Playing$du = $smtc.DisplayUpdater$du.Type = [Windows.Media.MediaPlaybackType]::Music
$fileW = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($webp)) ([Windows.Storage.StorageFile])$du.MusicProperties.Title = 'PHASE1-WEBP'$du.MusicProperties.Artist = 'SMTC test'$du.Thumbnail = [Windows.Storage.Streams.RandomAccessStreamReference]::CreateFromFile($fileW)$du.Update()Write-Host 'PHASE1 (webp) live, 40s...'Start-Sleep 40
$fileJ = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($jpg)) ([Windows.Storage.StorageFile])$du.MusicProperties.Title = 'PHASE2-JPG'$du.Thumbnail = [Windows.Storage.Streams.RandomAccessStreamReference]::CreateFromFile($fileJ)$du.Update()Write-Host 'PHASE2 (jpg) live, 40s...'Start-Sleep 40Write-Host 'done'观察 SMTC 面板,PHASE1 显示 WebP 缩略图,PHASE2 显示 JPG 缩略图。若 PHASE1 不显示,PHASE2 显示,则说明系统不解码 WebP。
调试技巧
SMTC 的问题大多是静默失败,调试时可以用这些手段:
- 确认初始化链路:在
AudioService.init和AudioServiceWin.configure里打日志,确认initializeSMTC真的被调用。 - 确认媒体信息在更新:监听
mediaItem/playbackStatestream,确认播放时确实在广播。 - 验证封面格式:把封面 URL 手动下载下来看扩展名和实际格式。如果 URL 是
.webp,先怀疑格式问题。 - JPG vs WebP 对比:同一张图分别用 JPG 和 WebP 变体测试,能快速定位是不是解码问题。
- 看系统日志:WinRT 的 SMTC 对缩略图解码失败没有回调,但可以观察媒体浮窗是否出现、按钮是否可点。
总结
这次接入 Windows SMTC,三层坑层层递进,每一层都是”静默失败”:
| 层 | 现象 | 根因 |
|---|---|---|
| 入口 | 加依赖后毫无效果 | Windows 启动分支没调 setupServiceLocator(),handler 为 null |
| 封面来源 | 封面加载不出来 | audio_service_win 丢弃 artCacheFile,走系统网络栈 |
| 封面格式 | 本地文件也加载不出来 | Windows 系统媒体面板不解码 WebP |
几个值得记住的教训:
- 静默失败比报错更难排查。WinRT 的 SMTC 对缩略图解码失败没有任何回调,只能靠”换一种输入验证”来定位(比如 JPG vs WebP 对比)。
- 跨平台库的”其他平台正常”往往掩盖了平台差异。Android/iOS 正常不代表 Windows 正常——它们的解码发生在应用进程内,而 Windows 的媒体浮窗是 shell 进程在解码。
- 图床的处理参数后缀是可以换的。B 站图床原生支持
@...q.jpg,这比在应用里做图片转码要省事得多。 - 排查顺序很重要:先确认初始化链路走到,再确认数据在更新,最后才怀疑格式/解码——每一层都验证过,才能定位到真正的根因。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





