第 37 章 全局媒体键与 OS 媒体中心
本章目标
- 响应全局媒体键(▶︎ ⏸ ⏭ ⏮ ⏹),哪怕你在别的应用里。
- 接入各平台的"当前播放"系统:
- macOS:
MediaPlayer.framework(Now Playing / Touch Bar)。 - Windows: SMTC(System Media Transport Controls)。
- Linux: MPRIS via D-Bus。
- macOS:
一个库覆盖三平台:souvlaki。
一、依赖
# Cargo.toml
souvlaki = "0.7"
raw-window-handle = "0.6"
二、初始化 MediaControls
// core/audio/media_controls.rs
use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig};
use std::time::Duration;
pub struct OsMedia {
controls: MediaControls,
}
impl OsMedia {
pub fn new(app: &tauri::AppHandle) -> anyhow::Result<Self> {
// Windows 需要 HWND
#[cfg(target_os = "windows")]
let hwnd = {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
let win = app.get_webview_window("main").unwrap();
let handle = win.window_handle()?;
match handle.as_raw() {
RawWindowHandle::Win32(h) => Some(h.hwnd.get() as _),
_ => None,
}
};
#[cfg(not(target_os = "windows"))]
let hwnd = None;
let config = PlatformConfig {
dbus_name: "com.cloudtone.player",
display_name: "CloudTone",
hwnd,
};
let controls = MediaControls::new(config)?;
Ok(Self { controls })
}
pub fn attach(&mut self, tx: tokio::sync::mpsc::Sender<PlayerCmd>) -> anyhow::Result<()> {
use souvlaki::MediaControlEvent as E;
self.controls.attach(move |ev| {
let tx = tx.clone();
tokio::spawn(async move {
let cmd = match ev {
E::Play => Some(PlayerCmd::Play),
E::Pause => Some(PlayerCmd::Pause),
E::Toggle => Some(PlayerCmd::Toggle),
E::Next => Some(PlayerCmd::Next),
E::Previous => Some(PlayerCmd::Prev),
E::Stop => Some(PlayerCmd::Stop),
E::SetPosition(MediaPosition(d)) => Some(PlayerCmd::Seek(d.as_secs_f64())),
_ => None,
};
if let Some(c) = cmd { let _ = tx.send(c).await; }
});
})?;
Ok(())
}
pub fn update_song(&mut self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, duration: Duration) {
let _ = self.controls.set_metadata(MediaMetadata {
title: Some(title),
artist: Some(artist),
album: Some(album),
cover_url,
duration: Some(duration),
});
}
pub fn update_state(&mut self, playing: bool, progress: Duration) {
let mp = if playing { MediaPlayback::Playing { progress: Some(MediaPosition(progress)) } }
else { MediaPlayback::Paused { progress: Some(MediaPosition(progress)) } };
let _ = self.controls.set_playback(mp);
}
}
三、集成到 Player
// InnerPlayer 有 Option<OsMedia>
impl InnerPlayer {
pub async fn init_os_media(&mut self, app: &tauri::AppHandle, tx: mpsc::Sender<PlayerCmd>) -> anyhow::Result<()> {
let mut media = OsMedia::new(app)?;
media.attach(tx)?;
self.os_media = Some(media);
Ok(())
}
}
播放状态变化时同步:
// 每次 Load 成功
if let Some(m) = &mut self.os_media {
m.update_song(&song.title, song.artist.as_deref().unwrap_or(""), song.album.as_deref().unwrap_or(""),
cover_url.as_deref(), Duration::from_millis(song.duration_ms as u64));
}
// 每秒
if let Some(m) = &mut self.os_media {
m.update_state(self.playing, Duration::from_secs_f64(self.progress));
}
四、媒体键快捷键(回退方案)
有些 Linux 桌面环境不转发媒体键到 MPRIS。用 tauri-plugin-global-shortcut 注册:
use tauri_plugin_global_shortcut::{Code, Modifiers, ShortcutState};
app.global_shortcut().on_shortcut("MediaPlayPause", |app, _, state| {
if state.state() == ShortcutState::Pressed {
let _ = app.emit("media:toggle", ());
}
}).ok();
app.global_shortcut().on_shortcut("MediaTrackNext", |app, _, _| {
let _ = app.emit("media:next", ());
}).ok();
五、封面在系统卡片里显示
macOS / Windows 的 Now Playing 需要一个 URL 或本地路径:
- 本地:
file:///path/to/cover.jpg - 网络:
https://.../cover.jpg
通常先写到 $APPCACHE/covers/<hash>.jpg,用 file:// 传入:
let cover_url = cache_dir.join(format!("{}.jpg", hash));
let url_str = url::Url::from_file_path(&cover_url).unwrap().to_string();
六、权限说明
macOS:App 首次调用 MediaPlayer 需要"媒体与 Apple Music"权限,系统会自动弹出。签名后才能稳定生效,调试期常见"显示不出来",第 47 章打包签名后解决。
Windows:SMTC 只在 UWP / 签名桌面应用里保证稳定,未签名的 dev build 偶尔不出现。
本章小结
souvlaki统一了三平台的媒体中心 API。- 保持
update_state/update_song与真实播放状态同步至关重要。 - 签名之后功能更稳定。
动手时刻
- macOS 上打开控制中心,看到 CloudTone 歌曲信息。
- 按笔记本键盘的 F8(播放/暂停),切换播放。
下一章:在线音源的可插拔 Provider 架构。