Initial commit — ferrosonic terminal Subsonic client
Terminal-based Subsonic music client in Rust featuring bit-perfect audio playback via PipeWire sample rate switching, gapless playback, MPRIS2 desktop integration, cava audio visualizer with theme-matched gradients, 13 built-in color themes with custom TOML theme support, mouse controls, artist/album browser, playlist support, and play queue management. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
5
src/mpris/mod.rs
Normal file
5
src/mpris/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! MPRIS2 D-Bus integration module
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod server;
|
||||
372
src/mpris/server.rs
Normal file
372
src/mpris/server.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
//! MPRIS2 D-Bus server implementation
|
||||
|
||||
use mpris_server::{
|
||||
zbus::{fdo, Result},
|
||||
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
|
||||
Server, Time, TrackId, Volume,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
use url::Url;
|
||||
|
||||
use crate::app::actions::AudioAction;
|
||||
use crate::app::state::{NowPlaying, PlaybackState, SharedState};
|
||||
use crate::config::Config;
|
||||
use crate::subsonic::auth::generate_auth_params;
|
||||
use crate::subsonic::models::Child;
|
||||
|
||||
/// API version for Subsonic
|
||||
const API_VERSION: &str = "1.16.1";
|
||||
/// Client name for Subsonic
|
||||
const CLIENT_NAME: &str = "ferrosonic";
|
||||
|
||||
/// Build a cover art URL from config and cover art ID
|
||||
fn build_cover_art_url(config: &Config, cover_art_id: &str) -> Option<String> {
|
||||
if config.base_url.is_empty() || cover_art_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (salt, token) = generate_auth_params(&config.password);
|
||||
let mut url = Url::parse(&format!("{}/rest/getCoverArt", config.base_url)).ok()?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("id", cover_art_id)
|
||||
.append_pair("u", &config.username)
|
||||
.append_pair("t", &token)
|
||||
.append_pair("s", &salt)
|
||||
.append_pair("v", API_VERSION)
|
||||
.append_pair("c", CLIENT_NAME);
|
||||
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
/// MPRIS server instance name
|
||||
const PLAYER_NAME: &str = "ferrosonic";
|
||||
|
||||
/// MPRIS2 player implementation
|
||||
pub struct MprisPlayer {
|
||||
state: SharedState,
|
||||
audio_tx: mpsc::Sender<AudioAction>,
|
||||
}
|
||||
|
||||
impl MprisPlayer {
|
||||
pub fn new(state: SharedState, audio_tx: mpsc::Sender<AudioAction>) -> Self {
|
||||
Self { state, audio_tx }
|
||||
}
|
||||
|
||||
async fn get_state(&self) -> (NowPlaying, Option<Child>, Config) {
|
||||
let state = self.state.read().await;
|
||||
let now_playing = state.now_playing.clone();
|
||||
let current_song = state.current_song().cloned();
|
||||
let config = state.config.clone();
|
||||
(now_playing, current_song, config)
|
||||
}
|
||||
}
|
||||
|
||||
impl RootInterface for MprisPlayer {
|
||||
async fn raise(&self) -> fdo::Result<()> {
|
||||
// We're a terminal app, can't raise
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn quit(&self) -> fdo::Result<()> {
|
||||
let mut state = self.state.write().await;
|
||||
state.should_quit = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn can_quit(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn fullscreen(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn set_fullscreen(&self, _fullscreen: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn can_raise(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn has_track_list(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn identity(&self) -> fdo::Result<String> {
|
||||
Ok("Termsonic".to_string())
|
||||
}
|
||||
|
||||
async fn desktop_entry(&self) -> fdo::Result<String> {
|
||||
Ok("ferrosonic".to_string())
|
||||
}
|
||||
|
||||
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
|
||||
Ok(vec!["http".to_string(), "https".to_string()])
|
||||
}
|
||||
|
||||
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
|
||||
Ok(vec![
|
||||
"audio/mpeg".to_string(),
|
||||
"audio/flac".to_string(),
|
||||
"audio/ogg".to_string(),
|
||||
"audio/wav".to_string(),
|
||||
"audio/x-wav".to_string(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerInterface for MprisPlayer {
|
||||
async fn next(&self) -> fdo::Result<()> {
|
||||
let _ = self.audio_tx.send(AudioAction::Next).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn previous(&self) -> fdo::Result<()> {
|
||||
let _ = self.audio_tx.send(AudioAction::Previous).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pause(&self) -> fdo::Result<()> {
|
||||
let _ = self.audio_tx.send(AudioAction::Pause).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn play_pause(&self) -> fdo::Result<()> {
|
||||
let _ = self.audio_tx.send(AudioAction::TogglePause).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> fdo::Result<()> {
|
||||
let _ = self.audio_tx.send(AudioAction::Stop).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn play(&self) -> fdo::Result<()> {
|
||||
let _ = self.audio_tx.send(AudioAction::Resume).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seek(&self, offset: Time) -> fdo::Result<()> {
|
||||
let offset_secs = offset.as_micros() as f64 / 1_000_000.0;
|
||||
let _ = self
|
||||
.audio_tx
|
||||
.send(AudioAction::SeekRelative(offset_secs))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_position(&self, _track_id: TrackId, position: Time) -> fdo::Result<()> {
|
||||
let position_secs = position.as_micros() as f64 / 1_000_000.0;
|
||||
let _ = self.audio_tx.send(AudioAction::Seek(position_secs)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
|
||||
// Not supported for now
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
|
||||
let (now_playing, _, _) = self.get_state().await;
|
||||
Ok(match now_playing.state {
|
||||
PlaybackState::Playing => PlaybackStatus::Playing,
|
||||
PlaybackState::Paused => PlaybackStatus::Paused,
|
||||
PlaybackState::Stopped => PlaybackStatus::Stopped,
|
||||
})
|
||||
}
|
||||
|
||||
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
|
||||
Ok(LoopStatus::None)
|
||||
}
|
||||
|
||||
async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(1.0)
|
||||
}
|
||||
|
||||
async fn set_rate(&self, _rate: PlaybackRate) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shuffle(&self) -> fdo::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn set_shuffle(&self, _shuffle: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn metadata(&self) -> fdo::Result<Metadata> {
|
||||
let (_now_playing, current_song, config) = self.get_state().await;
|
||||
|
||||
let mut metadata = Metadata::new();
|
||||
|
||||
if let Some(song) = current_song {
|
||||
metadata.set_trackid(
|
||||
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
|
||||
.flatten(),
|
||||
);
|
||||
metadata.set_title(Some(song.title));
|
||||
metadata.set_artist(song.artist.map(|a| vec![a]));
|
||||
metadata.set_album(song.album);
|
||||
|
||||
if let Some(duration) = song.duration {
|
||||
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
|
||||
}
|
||||
|
||||
if let Some(track) = song.track {
|
||||
metadata.set_track_number(Some(track));
|
||||
}
|
||||
|
||||
if let Some(disc) = song.disc_number {
|
||||
metadata.set_disc_number(Some(disc));
|
||||
}
|
||||
|
||||
// Add cover art URL
|
||||
if let Some(ref cover_art_id) = song.cover_art {
|
||||
if let Some(cover_url) = build_cover_art_url(&config, cover_art_id) {
|
||||
metadata.set_art_url(Some(cover_url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
async fn volume(&self) -> fdo::Result<Volume> {
|
||||
Ok(1.0)
|
||||
}
|
||||
|
||||
async fn set_volume(&self, volume: Volume) -> Result<()> {
|
||||
let volume_int = (volume * 100.0) as i32;
|
||||
let _ = self.audio_tx.send(AudioAction::SetVolume(volume_int)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn position(&self) -> fdo::Result<Time> {
|
||||
let (now_playing, _, _) = self.get_state().await;
|
||||
Ok(Time::from_micros(
|
||||
(now_playing.position * 1_000_000.0) as i64,
|
||||
))
|
||||
}
|
||||
|
||||
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(1.0)
|
||||
}
|
||||
|
||||
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(1.0)
|
||||
}
|
||||
|
||||
async fn can_go_next(&self) -> fdo::Result<bool> {
|
||||
let state = self.state.read().await;
|
||||
Ok(state
|
||||
.queue_position
|
||||
.map(|p| p + 1 < state.queue.len())
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
async fn can_go_previous(&self) -> fdo::Result<bool> {
|
||||
let state = self.state.read().await;
|
||||
Ok(state.queue_position.map(|p| p > 0).unwrap_or(false))
|
||||
}
|
||||
|
||||
async fn can_play(&self) -> fdo::Result<bool> {
|
||||
let state = self.state.read().await;
|
||||
Ok(!state.queue.is_empty())
|
||||
}
|
||||
|
||||
async fn can_pause(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn can_seek(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn can_control(&self) -> fdo::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the MPRIS server
|
||||
pub async fn start_mpris_server(
|
||||
state: SharedState,
|
||||
audio_tx: mpsc::Sender<AudioAction>,
|
||||
) -> Result<Server<MprisPlayer>> {
|
||||
info!("Starting MPRIS2 server");
|
||||
|
||||
let player = MprisPlayer::new(state, audio_tx);
|
||||
let server = Server::new(PLAYER_NAME, player).await?;
|
||||
|
||||
info!(
|
||||
"MPRIS2 server started as org.mpris.MediaPlayer2.{}",
|
||||
PLAYER_NAME
|
||||
);
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Update MPRIS properties when state changes
|
||||
pub async fn update_mpris_properties(
|
||||
server: &Server<MprisPlayer>,
|
||||
state: &SharedState,
|
||||
) -> Result<()> {
|
||||
let state = state.read().await;
|
||||
|
||||
// Emit property changes
|
||||
server
|
||||
.properties_changed([
|
||||
Property::PlaybackStatus(match state.now_playing.state {
|
||||
PlaybackState::Playing => PlaybackStatus::Playing,
|
||||
PlaybackState::Paused => PlaybackStatus::Paused,
|
||||
PlaybackState::Stopped => PlaybackStatus::Stopped,
|
||||
}),
|
||||
Property::CanGoNext(
|
||||
state
|
||||
.queue_position
|
||||
.map(|p| p + 1 < state.queue.len())
|
||||
.unwrap_or(false),
|
||||
),
|
||||
Property::CanGoPrevious(state.queue_position.map(|p| p > 0).unwrap_or(false)),
|
||||
])
|
||||
.await?;
|
||||
|
||||
// Update metadata if we have a current song
|
||||
if let Some(song) = state.current_song() {
|
||||
let mut metadata = Metadata::new();
|
||||
metadata.set_trackid(
|
||||
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
|
||||
.flatten(),
|
||||
);
|
||||
metadata.set_title(Some(song.title.clone()));
|
||||
metadata.set_artist(song.artist.clone().map(|a| vec![a]));
|
||||
metadata.set_album(song.album.clone());
|
||||
|
||||
if let Some(duration) = song.duration {
|
||||
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
|
||||
}
|
||||
|
||||
// Add cover art URL
|
||||
if let Some(ref cover_art_id) = song.cover_art {
|
||||
if let Some(cover_url) = build_cover_art_url(&state.config, cover_art_id) {
|
||||
metadata.set_art_url(Some(cover_url));
|
||||
}
|
||||
}
|
||||
|
||||
server
|
||||
.properties_changed([Property::Metadata(metadata)])
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user