use std::sync::Arc;
use std::time::{Duration, Instant};
use mineral_audio::AudioSnapshot;
use mineral_media::{LoopMode, MediaCommand, MediaConfig, MediaService, NowPlaying, PlaybackState};
use mineral_model::{Lyrics, MediaUrl, Song, SongId};
use mineral_protocol::{PlayMode, Repeat};
use crate::player::PlayerCore;
pub(crate) fn start(player: PlayerCore) -> Result<(), mineral_media::Error> {
let cmd_player = player.clone();
let on_command: Arc<dyn Fn(MediaCommand) + Send + Sync> =
Arc::new(move |cmd| handle_command(&cmd_player, cmd));
let config = MediaConfig::builder()
.dbus_name("mineral")
.display_name("mineral")
.build();
let service = MediaService::spawn(&config, on_command)?;
tokio::spawn(report_loop(player, service));
Ok(())
}
fn handle_command(player: &PlayerCore, cmd: MediaCommand) {
use mineral_stats::Actor;
let audio = player.audio();
match cmd {
MediaCommand::Play => player.resume_playback(Actor::User),
MediaCommand::Pause => player.pause_playback(Actor::User),
MediaCommand::Toggle => player.toggle_playback(Actor::User),
MediaCommand::Next => player.next_song(Actor::User),
MediaCommand::Previous => player.prev_or_restart(Actor::User),
MediaCommand::Stop => player.stop_playback(),
MediaCommand::SeekForward(delta) => {
let pos = audio.snapshot().position_ms;
player.seek_playback(pos.saturating_add(dur_ms(delta)), Actor::User);
}
MediaCommand::SeekBackward(delta) => {
let pos = audio.snapshot().position_ms;
player.seek_playback(pos.saturating_sub(dur_ms(delta)), Actor::User);
}
MediaCommand::SetPosition(at) => player.seek_playback(dur_ms(at), Actor::User),
MediaCommand::SetShuffle(on) => {
let mode = player.with_state(|st| st.play_mode);
player.set_play_mode(mode.with_shuffle(on), Actor::User);
}
MediaCommand::SetLoop(loop_mode) => {
let mode = player.with_state(|st| st.play_mode);
player.set_play_mode(mode.with_repeat(loop_to_repeat(loop_mode)), Actor::User);
}
}
}
fn loop_to_repeat(mode: LoopMode) -> Repeat {
match mode {
LoopMode::None => Repeat::Off,
LoopMode::Track => Repeat::One,
LoopMode::Playlist => Repeat::All,
}
}
fn repeat_to_loop(repeat: Repeat) -> LoopMode {
match repeat {
Repeat::Off => LoopMode::None,
Repeat::One => LoopMode::Track,
Repeat::All => LoopMode::Playlist,
}
}
fn dur_ms(d: Duration) -> u64 {
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
}
#[derive(Clone, Copy, Default, PartialEq, Eq)]
struct LyricsPresence {
words: bool,
lrc: bool,
translation: bool,
romanization: bool,
}
impl LyricsPresence {
fn of(lyrics: Option<&Lyrics>) -> Self {
match lyrics {
None => Self::default(),
Some(l) => Self {
words: mineral_model::has_words(&l.lines),
lrc: mineral_model::has_timed(&l.lines),
translation: l.has_translation(),
romanization: l.has_romanization(),
},
}
}
}
fn looks_like_seek(
prev_ms: u64,
actual_ms: u64,
elapsed_ms: u64,
was_playing: bool,
threshold_ms: u64,
) -> bool {
let expected = if was_playing {
prev_ms.saturating_add(elapsed_ms)
} else {
prev_ms
};
actual_ms.abs_diff(expected) > threshold_ms
}
async fn report_loop(player: PlayerCore, service: MediaService) {
let mut tick = tokio::time::interval(Duration::from_millis(player.media_report_interval_ms()));
let seek_threshold_ms = player.media_seek_threshold_ms();
let mut last_song_id = Option::<SongId>::None;
let mut last_presence = LyricsPresence::default();
let mut last_pos = Option::<u64>::None;
let mut last_tick = Instant::now();
let mut last_playing = false;
let mut last_play_mode = Option::<PlayMode>::None;
loop {
tick.tick().await;
let now = Instant::now();
let (current_song, current_lyrics, play_mode) = player.with_state(|st| {
(
st.current_song.clone(),
st.current_lyrics.clone(),
st.play_mode,
)
});
let audio = player.audio().snapshot();
let cur_id = current_song.as_ref().map(|s| s.id.clone());
let presence = LyricsPresence::of(current_lyrics.as_ref());
let song_changed = cur_id != last_song_id;
if song_changed || presence != last_presence {
if let Some(song) = ¤t_song {
let now_playing = build_now_playing(song, current_lyrics.as_ref());
if let Err(e) = service.set_now_playing(&now_playing) {
mineral_log::warn!(target: "media", error = mineral_log::chain(&e), "set_now_playing failed");
}
#[cfg(target_os = "macos")]
if song_changed {
spawn_artwork_fetch(service.clone(), song.cover_url.clone());
}
}
last_song_id = cur_id;
last_presence = presence;
}
if current_song.is_some() {
let elapsed_ms =
u64::try_from(now.duration_since(last_tick).as_millis()).unwrap_or(u64::MAX);
if let Some(prev) = last_pos
&& looks_like_seek(
prev,
audio.position_ms,
elapsed_ms,
last_playing,
seek_threshold_ms,
)
&& let Err(e) = service.notify_seek(Duration::from_millis(audio.position_ms))
{
mineral_log::warn!(target: "media", error = mineral_log::chain(&e), "notify_seek failed");
}
}
last_pos = Some(audio.position_ms);
last_tick = now;
last_playing = audio.playing;
if last_play_mode != Some(play_mode) {
if let Err(e) = service.set_shuffle(play_mode.shuffle()) {
mineral_log::warn!(target: "media", error = mineral_log::chain(&e), "set_shuffle failed");
}
if let Err(e) = service.set_loop(repeat_to_loop(play_mode.repeat())) {
mineral_log::warn!(target: "media", error = mineral_log::chain(&e), "set_loop failed");
}
last_play_mode = Some(play_mode);
}
let (state, position) = playback_of(current_song.as_ref(), &audio);
if let Err(e) = service.set_playback(state, position) {
mineral_log::warn!(target: "media", error = mineral_log::chain(&e), "set_playback failed");
}
}
}
fn playback_of(
current_song: Option<&Song>,
audio: &AudioSnapshot,
) -> (PlaybackState, Option<Duration>) {
if current_song.is_none() {
return (PlaybackState::Stopped, None);
}
let state = if audio.playing {
PlaybackState::Playing
} else {
PlaybackState::Paused
};
(state, Some(Duration::from_millis(audio.position_ms)))
}
fn build_now_playing(song: &Song, lyrics: Option<&Lyrics>) -> NowPlaying {
let artist = if song.artists.is_empty() {
None
} else {
let names = song
.artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>();
Some(names.join(" / "))
};
let builder = NowPlaying::builder()
.title(Some(song.name.clone()))
.artist(artist)
.album(song.album.as_ref().map(|a| a.name.clone()))
.cover_url(song.cover_url.as_ref().map(cover_to_url))
.duration(song.duration_ms.map(Duration::from_millis));
match lyrics {
None => builder.build(),
Some(l) => builder
.original(l.lines.clone())
.translation(l.translation_lines())
.romanization(l.romanization_lines())
.build(),
}
}
fn cover_to_url(url: &MediaUrl) -> String {
match url.as_remote() {
Some(remote) => remote.as_str().to_owned(),
None => format!("file://{url}"),
}
}
#[cfg(target_os = "macos")]
fn spawn_artwork_fetch(service: MediaService, cover: Option<MediaUrl>) {
let Some(cover) = cover else {
return;
};
tokio::spawn(async move {
match load_cover_bytes(&cover).await {
Ok(bytes) => {
if let Err(e) = service.set_artwork(&bytes) {
mineral_log::warn!(target: "media", error = mineral_log::chain(&e), "set_artwork failed");
}
}
Err(e) => {
mineral_log::debug!(target: "media", error = mineral_log::chain(&e), "拉取封面字节失败,跳过 artwork");
}
}
});
}
#[cfg(target_os = "macos")]
#[derive(Debug, thiserror::Error)]
enum CoverError {
#[error("cover HTTP request failed")]
Http(#[from] reqwest::Error),
#[error("local cover could not be read")]
File(#[from] std::io::Error),
#[error("cover locator has no remote or local transport")]
UnsupportedLocator,
}
#[cfg(target_os = "macos")]
async fn load_cover_bytes(cover: &MediaUrl) -> Result<Vec<u8>, CoverError> {
if let Some(remote) = cover.as_remote() {
let client = reqwest::Client::builder().build()?;
let bytes = client
.get(remote.as_str())
.send()
.await?
.error_for_status()?
.bytes()
.await?;
return Ok(bytes.to_vec());
}
if let Some(path) = cover.as_local() {
return Ok(tokio::fs::read(path).await?);
}
Err(CoverError::UnsupportedLocator)
}
#[cfg(test)]
mod tests {
use super::looks_like_seek;
#[test]
fn jitter_within_threshold_not_seek() {
assert!(!looks_like_seek(
10_000, 10_380, 200, true, 1000
));
}
#[test]
fn seek_forward_is_seek() {
assert!(looks_like_seek(
10_000, 70_000, 200, true, 1000
));
}
#[test]
fn seek_backward_is_seek() {
assert!(looks_like_seek(
60_000, 5_000, 200, true, 1000
));
}
#[test]
fn paused_position_steady_not_seek() {
assert!(!looks_like_seek(
10_000, 10_000, 5_000, false, 1000
));
}
}