Skip to content

Commit fcdeecc

Browse files
committed
Fix volume reset on connect; add seek event forwarding
1 parent b0af1fb commit fcdeecc

3 files changed

Lines changed: 225 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ tokio = { version = "1", features = [
184184
"signal",
185185
"sync",
186186
"process",
187+
"net",
188+
"io-util",
187189
] }
188190
url = "2.2"
189191

src/main.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ struct Setup {
253253
client_id: Option<String>,
254254
get_token: bool,
255255
save_token: Option<String>,
256+
lms: Option<String>,
257+
lms_auth: Option<String>,
258+
player_mac: Option<String>,
256259
}
257260

258261
async fn get_setup() -> Setup {
@@ -2060,6 +2063,9 @@ async fn get_setup() -> Setup {
20602063
} else {
20612064
Some(client_id)
20622065
},
2066+
lms: opt_str(LYRION_MUSIC_SERVER),
2067+
lms_auth: opt_str(LMS_AUTH),
2068+
player_mac: opt_str(PLAYER_MAC),
20632069
}
20642070
}
20652071

@@ -2217,9 +2223,17 @@ async fn main() {
22172223
let player_config = setup.player_config.clone();
22182224

22192225
let soft_volume = mixer.get_soft_volume();
2226+
#[cfg(not(feature = "spotty"))]
22202227
let format = setup.format;
2228+
#[cfg(not(feature = "spotty"))]
22212229
let backend = setup.backend;
2230+
#[cfg(not(feature = "spotty"))]
22222231
let device = setup.device.clone();
2232+
#[cfg(feature = "spotty")]
2233+
let player = Player::new(player_config, session.clone(), soft_volume, move || {
2234+
spotty::ConnectNullSink::open(None, AudioFormat::default())
2235+
});
2236+
#[cfg(not(feature = "spotty"))]
22232237
let player = Player::new(player_config, session.clone(), soft_volume, move || {
22242238
(backend)(device, format)
22252239
});
@@ -2238,6 +2252,24 @@ async fn main() {
22382252
}
22392253
}
22402254

2255+
#[cfg(feature = "spotty")]
2256+
{
2257+
let lms = spotty::LMS::new(
2258+
setup.lms.clone(),
2259+
setup.player_mac.clone(),
2260+
setup.lms_auth.clone(),
2261+
);
2262+
if lms.is_configured() {
2263+
let mut lms_events = player.get_player_event_channel();
2264+
tokio::spawn(async move {
2265+
let mut current_track: Option<String> = None;
2266+
while let Some(event) = lms_events.recv().await {
2267+
lms.handle_player_event(&event, &mut current_track).await;
2268+
}
2269+
});
2270+
}
2271+
}
2272+
22412273
loop {
22422274
tokio::select! {
22432275
credentials = async {

src/spotty.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,194 @@ pub async fn play_track(
145145
}
146146
}
147147
}
148+
149+
// LMS (Lyrion Music Server) Spotify Connect integration
150+
151+
use librespot::playback::audio_backend::{Sink, SinkResult};
152+
use librespot::playback::convert::Converter;
153+
use librespot::playback::decoder::AudioPacket;
154+
use librespot::playback::player::PlayerEvent;
155+
use std::sync::atomic::{AtomicBool, Ordering};
156+
use std::sync::Arc;
157+
use std::time::Instant;
158+
use tokio::io::AsyncWriteExt;
159+
use tokio::net::TcpStream;
160+
161+
/// Rate-limited null sink for Spotify Connect daemon mode.
162+
///
163+
/// Discards decoded audio while sleeping between writes to maintain accurate
164+
/// real-time playback position (needed so Spirc reports correct state to Spotify).
165+
/// Unlike the pipe/StdoutSink, this sink does NOT call exit() on stop(), allowing
166+
/// the Connect daemon to handle track transitions and pause/resume cleanly.
167+
pub struct ConnectNullSink {
168+
start: Instant,
169+
frames: u64,
170+
}
171+
172+
impl ConnectNullSink {
173+
pub fn open(_device: Option<String>, _format: AudioFormat) -> Box<dyn Sink> {
174+
Box::new(Self {
175+
start: Instant::now(),
176+
frames: 0,
177+
})
178+
}
179+
}
180+
181+
impl Sink for ConnectNullSink {
182+
fn start(&mut self) -> SinkResult<()> {
183+
self.start = Instant::now();
184+
self.frames = 0;
185+
Ok(())
186+
}
187+
188+
fn write(&mut self, packet: AudioPacket, _: &mut Converter) -> SinkResult<()> {
189+
if let AudioPacket::Samples(samples) = packet {
190+
// samples is stereo-interleaved f64; each pair is one frame
191+
self.frames += (samples.len() / librespot::playback::NUM_CHANNELS as usize) as u64;
192+
let expected_ns = self.frames * 1_000_000_000 / librespot::playback::SAMPLE_RATE as u64;
193+
let elapsed_ns = self.start.elapsed().as_nanos() as u64;
194+
if expected_ns > elapsed_ns {
195+
std::thread::sleep(std::time::Duration::from_nanos(expected_ns - elapsed_ns));
196+
}
197+
}
198+
Ok(())
199+
}
200+
}
201+
202+
#[derive(Clone)]
203+
pub struct LMS {
204+
host_port: Option<String>,
205+
player_mac: Option<String>,
206+
auth: Option<String>,
207+
/// Set to true when Spirc activates the session; the very next VolumeChanged
208+
/// event is Spotify's stored device volume being pushed back to us, not a
209+
/// user-driven change. We suppress it to avoid clobbering the LMS player's
210+
/// current volume.
211+
suppress_next_volume: Arc<AtomicBool>,
212+
}
213+
214+
impl LMS {
215+
pub fn new(
216+
host_port: Option<String>,
217+
player_mac: Option<String>,
218+
auth: Option<String>,
219+
) -> Self {
220+
Self {
221+
host_port,
222+
player_mac,
223+
auth: auth.map(|a| a.trim().to_string()),
224+
suppress_next_volume: Arc::new(AtomicBool::new(false)),
225+
}
226+
}
227+
228+
pub fn is_configured(&self) -> bool {
229+
self.host_port.is_some() && self.player_mac.is_some()
230+
}
231+
232+
async fn notify(&self, cmd: &str, param1: &str, param2: &str) {
233+
let (host_port, player_mac) = match (&self.host_port, &self.player_mac) {
234+
(Some(h), Some(m)) => (h.as_str(), m.as_str()),
235+
_ => return,
236+
};
237+
238+
let mut cmd_array: Vec<serde_json::Value> =
239+
vec![serde_json::json!("spottyconnect"), serde_json::json!(cmd)];
240+
if !param1.is_empty() {
241+
cmd_array.push(serde_json::json!(param1));
242+
}
243+
if !param2.is_empty() {
244+
cmd_array.push(serde_json::json!(param2));
245+
}
246+
247+
let body = serde_json::json!({
248+
"id": 1,
249+
"method": "slim.request",
250+
"params": [player_mac, cmd_array],
251+
})
252+
.to_string();
253+
254+
let auth_line = self
255+
.auth
256+
.as_ref()
257+
.map(|a| format!("Authorization: Basic {a}\r\n"))
258+
.unwrap_or_default();
259+
260+
let request = format!(
261+
"POST /jsonrpc.js HTTP/1.0\r\nHost: {host_port}\r\nContent-Type: application/json\r\nContent-Length: {len}\r\n{auth_line}\r\n{body}",
262+
len = body.len()
263+
);
264+
265+
match TcpStream::connect(host_port).await {
266+
Ok(mut stream) => {
267+
if let Err(e) = stream.write_all(request.as_bytes()).await {
268+
warn!("LMS notification write failed: {e}");
269+
}
270+
}
271+
Err(e) => {
272+
warn!("Failed to connect to LMS at {host_port}: {e}");
273+
}
274+
}
275+
}
276+
277+
pub async fn handle_player_event(
278+
&self,
279+
event: &PlayerEvent,
280+
current_track: &mut Option<String>,
281+
) {
282+
match event {
283+
PlayerEvent::Playing { track_id, .. } => {
284+
let id = match track_id.to_id() {
285+
Ok(id) => id,
286+
Err(e) => {
287+
warn!("LMS: failed to get track id: {e}");
288+
return;
289+
}
290+
};
291+
if current_track.as_deref() == Some(id.as_str()) {
292+
// Same track (e.g. seek or buffer-underrun re-emit), no action needed
293+
return;
294+
}
295+
let old = current_track.replace(id.clone());
296+
if let Some(old_id) = old {
297+
self.notify("change", &id, &old_id).await;
298+
} else {
299+
self.notify("start", &id, "").await;
300+
}
301+
}
302+
PlayerEvent::Stopped { .. } | PlayerEvent::Paused { .. } => {
303+
if current_track.take().is_some() {
304+
self.notify("stop", "", "").await;
305+
}
306+
}
307+
PlayerEvent::VolumeChanged { volume } => {
308+
// Suppress the activation-time volume push from Spotify. When
309+
// Spirc connects to Spotify it immediately emits the device's
310+
// last-remembered volume (SessionConnected fires first, setting
311+
// this flag). That value comes from Spotify's state, not the
312+
// user, and would overwrite whatever LMS had set.
313+
if self.suppress_next_volume.swap(false, Ordering::Relaxed) {
314+
info!("LMS: suppressing activation-time volume reset from Spotify ({} -> {}%)",
315+
volume, *volume as u64 * 100 / 65535);
316+
return;
317+
}
318+
let pct = (*volume as u64 * 100 / 65535).to_string();
319+
self.notify("volume", &pct, "").await;
320+
}
321+
PlayerEvent::Seeked { position_ms, .. } => {
322+
// Send the exact position directly so the Perl handler can
323+
// seek LMS immediately without querying the REST API (which
324+
// frequently lags behind Spirc's WebSocket state by 500ms+).
325+
if current_track.is_some() {
326+
let pos_secs = (*position_ms as f64 / 1000.0).to_string();
327+
self.notify("seek", &pos_secs, "").await;
328+
}
329+
}
330+
PlayerEvent::SessionConnected { .. } => {
331+
// The next VolumeChanged will be Spirc pushing Spotify's stored
332+
// device volume; flag it for suppression.
333+
self.suppress_next_volume.store(true, Ordering::Relaxed);
334+
}
335+
_ => {}
336+
}
337+
}
338+
}

0 commit comments

Comments
 (0)