Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8fe221e
Allow cloning SPIRC
wisp3rwind Sep 17, 2024
d7951f1
add release date to AudioItem
wisp3rwind Sep 17, 2024
ae47002
add Spirc.seek_offset command
wisp3rwind Sep 17, 2024
8b78253
add initial MPRIS support using zbus
wisp3rwind Oct 1, 2024
89f26cd
feat(mpris): serve identity based on configured name
paulfariello Sep 23, 2025
e49c287
feat(mpris): Add set_volume handler
paulfariello Sep 23, 2025
c54b260
feat(mpris): Retry with pid specific name on NameTaken error
paulfariello Sep 23, 2025
019fa4c
feat(player): Send current state of player for all new player listeners
paulfariello Sep 23, 2025
4a26063
feat(mpris): Notify when volume changed
paulfariello Sep 23, 2025
fc17df4
fix(mpris): Remove done todo
paulfariello Sep 23, 2025
930e611
fix(mpris): Remove duplicated and commented function
paulfariello Sep 23, 2025
a7d90e9
fix(mpris): Add comment concerning non-support of setting playback rate
paulfariello Sep 23, 2025
05085b9
feat(mpris): Store metadata unserialized
paulfariello Sep 25, 2025
6b2d3c8
feat(mpris): Add debug logging
paulfariello Sep 25, 2025
48224fa
feat(mpris): Send biggest art url
paulfariello Sep 25, 2025
077f9ef
feat(mpris): Update track id on EndOfTrack
paulfariello Sep 25, 2025
18339b2
feat(player): Add position update option
paulfariello Sep 30, 2025
9d0b399
feat(mpris): Get position from player and provide it to MPRIS
paulfariello Sep 30, 2025
82f4a5e
feat(mpris): Check track_id when setting position
paulfariello Sep 30, 2025
2d199a3
chore(mpris): Remove useless comment
paulfariello Sep 30, 2025
23659f9
feat(mpris): Add support for desktop entry
paulfariello Sep 30, 2025
f4f330f
feat(mpris): Return error when trying to play/pause in wrong context
paulfariello Sep 30, 2025
90e64b2
feat(mpris): Signal when position changed
paulfariello Sep 30, 2025
867f537
chore(mpris): alias zbus::fdo::{Error, Result} for readability
paulfariello Oct 1, 2025
16d1c0f
feat(player): Allow for stopped event without track_id
paulfariello Oct 1, 2025
f8d6c42
feat(player): Rename position update interval option
paulfariello Oct 1, 2025
721375d
feat(mpris): Upgrade to zbus 5
paulfariello Oct 2, 2025
829ce15
feat(player): Add position_ms in Loading event
paulfariello Oct 2, 2025
3aaa31c
feat(changelog): Add mpris changelog
paulfariello Oct 8, 2025
6ca0583
fixup! add initial MPRIS support using zbus
paulfariello Nov 13, 2025
70c68f7
feat(player): Replace position update interval option with sensible d…
paulfariello Nov 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- [dbus/mpris] Add dbus/mpris support to allow controlling player (breaking)

## [0.8.0] - 2025-11-10

### Added
Expand Down
45 changes: 24 additions & 21 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ repository = "https://github.com/librespot-org/librespot"
edition = "2024"

[features]
default = ["native-tls", "rodio-backend", "with-libmdns"]
default = ["native-tls", "rodio-backend", "with-libmdns", "with-mpris"]

# TLS backends (mutually exclusive - compile-time checks in oauth/src/lib.rs)
# Note: Feature validation is in oauth crate since it's compiled first in the dependency tree.
Expand Down Expand Up @@ -132,6 +132,10 @@ with-dns-sd = ["librespot-discovery/with-dns-sd"]
# data.
passthrough-decoder = ["librespot-playback/passthrough-decoder"]

# MPRIS: Allow external tool to have access to playback
# status, metadata and to control the player.
with-mpris = ["dep:zbus", "dep:zvariant", "dep:time"]

[lib]
name = "librespot"
path = "src/lib.rs"
Expand Down Expand Up @@ -180,7 +184,10 @@ tokio = { version = "1", features = [
"sync",
"process",
] }
time = { version = "0.3", features = ["formatting"], optional = true }
url = "2.2"
zbus = { version = "5", default-features = false, features = ["tokio"], optional = true }
zvariant = { version = "5", default-features = false, optional = true }

[package.metadata.deb]
maintainer = "Librespot Organization <[email protected]>"
Expand Down
29 changes: 29 additions & 0 deletions connect/src/spirc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ enum SpircCommand {
RepeatTrack(bool),
Disconnect { pause: bool },
SetPosition(u32),
SeekOffset(i32),
SetVolume(u16),
Activate,
Transfer(Option<TransferRequest>),
Expand All @@ -142,6 +143,7 @@ const VOLUME_UPDATE_DELAY: Duration = Duration::from_millis(500);
const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200);

/// The spotify connect handle
#[derive(Clone)]
pub struct Spirc {
commands: mpsc::UnboundedSender<SpircCommand>,
}
Expand Down Expand Up @@ -388,6 +390,13 @@ impl Spirc {
Ok(self.commands.send(SpircCommand::Load(command))?)
}

/// Seek to given offset.
///
/// Does nothing if we are not the active device.
pub fn seek_offset(&self, offset_ms: i32) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::SeekOffset(offset_ms))?)
}

/// Disconnects the current device and pauses the playback according the value.
///
/// Does nothing if we are not the active device.
Expand Down Expand Up @@ -677,6 +686,7 @@ impl SpircTask {
SpircCommand::Repeat(repeat) => self.handle_repeat_context(repeat)?,
SpircCommand::RepeatTrack(repeat) => self.handle_repeat_track(repeat),
SpircCommand::SetPosition(position) => self.handle_seek(position),
SpircCommand::SeekOffset(offset) => self.handle_seek_offset(offset),
SpircCommand::SetVolume(volume) => self.set_volume(volume),
SpircCommand::Load(command) => self.handle_load(command, None, None).await?,
};
Expand Down Expand Up @@ -1528,6 +1538,25 @@ impl SpircTask {
};
}

fn handle_seek_offset(&mut self, offset_ms: i32) {
let position_ms = match self.play_status {
SpircPlayStatus::Stopped => return,
SpircPlayStatus::LoadingPause { position_ms }
| SpircPlayStatus::LoadingPlay { position_ms }
| SpircPlayStatus::Paused { position_ms, .. } => position_ms,
SpircPlayStatus::Playing {
nominal_start_time, ..
} => {
let now = self.now_ms();
(now - nominal_start_time) as u32
}
};

let position_ms = ((position_ms as i32) + offset_ms).max(0) as u32;

self.handle_seek(position_ms);
}

fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> {
self.player.emit_shuffle_changed_event(shuffle);
self.connect_state.handle_shuffle(shuffle)
Expand Down
4 changes: 4 additions & 0 deletions metadata/src/audio/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub enum UniqueFields {
Track {
artists: ArtistsWithRole,
album: String,
album_date: Date,
album_artists: Vec<String>,
popularity: u8,
number: u32,
Expand Down Expand Up @@ -90,6 +91,8 @@ impl AudioItem {
let uri_string = uri.to_uri()?;
let album = track.album.name;

let album_date = track.album.date;

let album_artists = track
.album
.artists
Expand Down Expand Up @@ -123,6 +126,7 @@ impl AudioItem {
let unique_fields = UniqueFields::Track {
artists: track.artists_with_role,
album,
album_date,
album_artists,
popularity,
number,
Expand Down
66 changes: 60 additions & 6 deletions playback/src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ pub enum PlayerEvent {
},
// Fired when the player is stopped (e.g. by issuing a "stop" command to the player).
Stopped {
play_request_id: u64,
track_id: SpotifyUri,
play_request_id: Option<u64>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please tag in the changelog that this is a breaking change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

track_id: Option<SpotifyUri>,
},
// The player is delayed by loading a track.
Loading {
Expand Down Expand Up @@ -273,7 +273,8 @@ impl PlayerEvent {
play_request_id, ..
}
| Stopped {
play_request_id, ..
play_request_id: Some(play_request_id),
..
}
| PositionCorrection {
play_request_id, ..
Expand Down Expand Up @@ -693,6 +694,7 @@ enum PlayerState {
play_request_id: u64,
start_playback: bool,
loader: Pin<Box<dyn FusedFuture<Output = Result<PlayerLoadedTrackData, ()>> + Send>>,
position_ms: u32,
},
Paused {
track_id: SpotifyUri,
Expand Down Expand Up @@ -1349,6 +1351,7 @@ impl Future for PlayerInternal {
ref track_id,
start_playback,
play_request_id,
..
} = self.state
{
// The loader may be terminated if we are trying to load the same track
Expand Down Expand Up @@ -1664,8 +1667,8 @@ impl PlayerInternal {

self.ensure_sink_stopped(false);
self.send_event(PlayerEvent::Stopped {
track_id,
play_request_id,
track_id: Some(track_id),
play_request_id: Some(play_request_id),
});
self.state = PlayerState::Stopped;
}
Expand Down Expand Up @@ -2143,6 +2146,7 @@ impl PlayerInternal {
play_request_id,
start_playback: play,
loader,
position_ms,
};

Ok(())
Expand Down Expand Up @@ -2286,7 +2290,57 @@ impl PlayerInternal {

PlayerCommand::SetSession(session) => self.session = session,

PlayerCommand::AddEventSender(sender) => self.event_senders.push(sender),
PlayerCommand::AddEventSender(sender) => {
// Send current player state to new event listener
Comment thread
paulfariello marked this conversation as resolved.
match self.state {
PlayerState::Loading {
ref track_id,
play_request_id,
position_ms,
..
} => {
let _ = sender.send(PlayerEvent::Loading {
play_request_id,
track_id: track_id.clone(),
position_ms,
});
}
PlayerState::Paused {
ref track_id,
play_request_id,
stream_position_ms,
..
} => {
let _ = sender.send(PlayerEvent::Paused {
play_request_id,
track_id: track_id.clone(),
position_ms: stream_position_ms,
});
}
PlayerState::Playing { ref audio_item, .. } => {
let audio_item = Box::new(audio_item.clone());
let _ = sender.send(PlayerEvent::TrackChanged { audio_item });
}
PlayerState::EndOfTrack {
play_request_id,
ref track_id,
..
} => {
let _ = sender.send(PlayerEvent::EndOfTrack {
play_request_id,
track_id: track_id.clone(),
});
}
PlayerState::Invalid | PlayerState::Stopped => {
let _ = sender.send(PlayerEvent::Stopped {
play_request_id: None,
track_id: None,
});
}
}

self.event_senders.push(sender);
}

PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback,

Expand Down
Loading
Loading