From 33fa2346f09189b180b46f821113f09436c4b4cf Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 24 Jul 2026 05:19:28 +0300 Subject: [PATCH] Connected Devices: added remote control --- Cargo.toml | 2 +- src/app/mod.rs | 47 +++++++++++++++++++++++++++++++++++++++++------ src/devices.rs | 35 ++++++++++++++++++++++++++++++++++- src/main.rs | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 521c5d7..4fc4997 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ unicode-width = "0.2.2" core-foundation = "0.10.1" [target."cfg(windows)".dependencies] -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi", "Win32_System_Console", "Win32_System_Pipes"] } [target."cfg(unix)".dependencies] libc = "0.2.186" diff --git a/src/app/mod.rs b/src/app/mod.rs index 0bfe0fa..95757fb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -251,7 +251,9 @@ fn sync_player_shared(state: &mut AppState, runtime: &Runtime) { } else { runtime.player.shared.audio_analysis() }; - publish_playback_snapshot(state, runtime); + if state.device_playback.role == state::DevicePlaybackRole::Active { + publish_playback_snapshot(state, runtime); + } } fn unix_time_ms() -> i64 { @@ -310,6 +312,27 @@ fn playback_track_to_ui( wire.to_track_item() } +fn track_playback_key(track: &crate::library::models::TrackItem) -> String { + if let Some(content_id) = track + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + .or_else(|| { + track + .fed + .as_ref() + .and_then(|fed| fed.content_id.as_deref()) + .and_then(music_dht::normalize_content_id) + }) + { + return format!("content:{content_id}"); + } + if let Some(fed) = &track.fed { + return format!("fed:{}:{}", fed.owner, fed.item_id); + } + format!("local:{}", track.id) +} + fn apply_playback_state_to_ui( state: &mut AppState, wire: &crate::devices::PlaybackStateWire, @@ -352,20 +375,31 @@ fn apply_playback_state_to_ui( } fn publish_playback_snapshot(state: &mut AppState, runtime: &Runtime) { + if state.device_playback.role != state::DevicePlaybackRole::Active { + return; + } + publish_playback_snapshot_with_active(state, runtime, true); +} + +fn publish_inactive_playback_snapshot(state: &mut AppState, runtime: &Runtime) { + publish_playback_snapshot_with_active(state, runtime, false); +} + +fn publish_playback_snapshot_with_active(state: &mut AppState, runtime: &Runtime, active: bool) { let Ok((device_id, device_name)) = runtime.devices.identity_summary() else { return; }; update_local_idle_since(state); state.device_playback.self_device_id = device_id.clone(); state.device_playback.self_device_name = device_name.clone(); - if state.device_playback.role == state::DevicePlaybackRole::Active { + if active { state.device_playback.active_device_id = Some(device_id.clone()); state.device_playback.active_device_name = Some(device_name.clone()); } let snapshot = crate::devices::PlaybackSnapshot { device_id, device_name, - active: state.device_playback.role == state::DevicePlaybackRole::Active, + active, updated_at_ms: unix_time_ms(), state: playback_state_from_ui(state), }; @@ -445,6 +479,7 @@ pub(crate) fn become_control_device( ) { if state.device_playback.role == state::DevicePlaybackRole::Active { runtime.player.stop(); + publish_inactive_playback_snapshot(state, runtime); } state.device_playback.role = state::DevicePlaybackRole::Control; state.device_playback.active_device_id = Some(snapshot.device_id.clone()); @@ -1978,7 +2013,7 @@ fn handle_playback_command( ) { match command { crate::devices::PlaybackCommand::SetState { state: wire } => { - let old_current_id = state.player.current.as_ref().map(|track| track.id); + let old_current_key = state.player.current.as_ref().map(track_playback_key); let old_playing = state.player.playing; let old_paused = state.player.paused; become_active_device(state, runtime, false); @@ -1993,8 +2028,8 @@ fn handle_playback_command( publish_playback_snapshot(state, runtime); return; } - let current_id = state.player.current.as_ref().map(|track| track.id); - if !old_playing || old_current_id != current_id { + let current_key = state.player.current.as_ref().map(track_playback_key); + if !old_playing || old_current_key != current_key { start_current_audio( state, runtime, diff --git a/src/devices.rs b/src/devices.rs index 8ef8136..a005b61 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -123,6 +123,37 @@ pub struct PlaybackTrack { } impl PlaybackTrack { + fn portable_placeholder_id(&self) -> i64 { + let key = self + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + .map(|content_id| format!("content:{content_id}")) + .or_else(|| { + self.fed + .as_ref() + .map(|fed| fed.content_id.as_str()) + .and_then(music_dht::normalize_content_id) + .map(|content_id| format!("content:{content_id}")) + }) + .or_else(|| { + self.fed + .as_ref() + .map(|fed| format!("fed:{}:{}", fed.owner, fed.item_id)) + }) + .unwrap_or_else(|| { + format!( + "remote:{}:{}:{}:{}", + self.id, self.title, self.release_title, self.duration_seconds + ) + }); + let hash = blake3::hash(key.as_bytes()); + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&hash.as_bytes()[..8]); + let positive = (i64::from_be_bytes(bytes) & i64::MAX).max(1); + -positive + } + pub fn from_track(track: &TrackItem) -> Self { Self { id: track.id, @@ -169,7 +200,7 @@ impl PlaybackTrack { .collect() }; TrackItem { - id: self.id, + id: self.portable_placeholder_id(), title: self.title.clone(), track_number: self.track_number, disc_number: self.disc_number, @@ -3453,6 +3484,8 @@ mod tests { let mut legacy_wire = wire.clone(); legacy_wire.file_path = "/Users/me/Music/song.mp3".to_string(); let restored = legacy_wire.to_track_item(); + assert!(restored.id < 0); + assert_ne!(restored.id, source.id); assert!(restored.file_path.is_empty()); assert_eq!(restored.content_id, source.content_id); } diff --git a/src/main.rs b/src/main.rs index 8313963..17c7ccc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,7 +126,42 @@ fn capture_stderr() { } } -#[cfg(not(unix))] +#[cfg(windows)] +fn capture_stderr() { + use std::io::BufRead as _; + use std::os::windows::io::FromRawHandle as _; + + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle}; + use windows_sys::Win32::System::Pipes::CreatePipe; + + unsafe { + let mut read = core::ptr::null_mut(); + let mut write = core::ptr::null_mut(); + if CreatePipe(&mut read, &mut write, core::ptr::null(), 0) == 0 { + return; + } + if SetStdHandle(STD_ERROR_HANDLE, write) == 0 { + CloseHandle(read); + CloseHandle(write); + return; + } + let reader = std::fs::File::from_raw_handle(read); + std::thread::Builder::new() + .name("stderr".to_string()) + .spawn(move || { + for line in std::io::BufReader::new(reader).lines() { + let Ok(line) = line else { break }; + if !line.trim().is_empty() { + tracing::warn!(target: "stderr", "{line}"); + } + } + }) + .ok(); + } +} + +#[cfg(not(any(unix, windows)))] fn capture_stderr() {} /// Kitty keyboard protocol, where supported, disambiguates Esc from alt-keys