Connected Devices: added remote control
This commit is contained in:
+5
-1
@@ -42,6 +42,7 @@ pub enum Action {
|
||||
DownloadSelected,
|
||||
RemoveFromQueue,
|
||||
ClearQueue,
|
||||
OpenConnectedDevices,
|
||||
GoToRelease,
|
||||
AddToPlaylist,
|
||||
NewPlaylist,
|
||||
@@ -99,7 +100,8 @@ impl Action {
|
||||
| Action::VolumeDown
|
||||
| Action::ToggleShuffle
|
||||
| Action::CycleRepeat
|
||||
| Action::ToggleVisualizer => Category::Playback,
|
||||
| Action::ToggleVisualizer
|
||||
| Action::OpenConnectedDevices => Category::Playback,
|
||||
Action::QueueAddNext
|
||||
| Action::QueueAddLast
|
||||
| Action::DownloadSelected
|
||||
@@ -147,6 +149,7 @@ impl Action {
|
||||
Action::ToggleShuffle => Some(":shuffle"),
|
||||
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
||||
Action::ClearQueue => Some(":clear"),
|
||||
Action::OpenConnectedDevices => None,
|
||||
Action::ToggleHelp => Some(":help"),
|
||||
Action::OpenSearch => Some("/text"),
|
||||
_ => None,
|
||||
@@ -188,6 +191,7 @@ impl Action {
|
||||
Action::DownloadSelected => "Federation: download to library".into(),
|
||||
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
||||
Action::ClearQueue => "Queue: clear".into(),
|
||||
Action::OpenConnectedDevices => "Connected devices…".into(),
|
||||
Action::GoToRelease => "Open the track's release".into(),
|
||||
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
||||
Action::NewPlaylist => "Create a playlist".into(),
|
||||
|
||||
@@ -137,4 +137,8 @@ pub enum AppEvent {
|
||||
DeviceConnectResult(Result<String, String>),
|
||||
/// Incoming pairing request that passed the invite-secret check.
|
||||
DevicePairingRequest(crate::devices::PendingPairing),
|
||||
/// Trusted device playback state, delivered by personal-device sync.
|
||||
DevicePlayback(crate::devices::PlaybackSnapshot),
|
||||
/// Playback command addressed to this device.
|
||||
PlaybackCommand(crate::devices::PlaybackCommand),
|
||||
}
|
||||
|
||||
+400
-5
@@ -3,7 +3,7 @@ mod cmdline;
|
||||
pub mod command;
|
||||
pub mod event;
|
||||
pub mod input;
|
||||
mod popup;
|
||||
pub(crate) mod popup;
|
||||
pub mod state;
|
||||
pub mod update;
|
||||
|
||||
@@ -98,6 +98,12 @@ pub async fn run(
|
||||
let federation = crate::federation::Federation::new(Arc::clone(&library), Arc::clone(&devices));
|
||||
state.federation.settings = federation.settings();
|
||||
state.federation.devices = Some(devices.status());
|
||||
if let Ok((device_id, device_name)) = devices.identity_summary() {
|
||||
state.device_playback.self_device_id = device_id.clone();
|
||||
state.device_playback.self_device_name = device_name.clone();
|
||||
state.device_playback.active_device_id = Some(device_id);
|
||||
state.device_playback.active_device_name = Some(device_name);
|
||||
}
|
||||
let player_events = event_tx.clone();
|
||||
let mut runtime = Runtime {
|
||||
event_tx,
|
||||
@@ -169,11 +175,212 @@ pub async fn run(
|
||||
}
|
||||
|
||||
fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
|
||||
if state.player.current.is_some() && !runtime.player_start_pending {
|
||||
if state.device_playback.is_control() {
|
||||
extrapolate_control_position(state);
|
||||
} else if state.player.current.is_some() && !runtime.player_start_pending {
|
||||
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
|
||||
state.player.paused = runtime.player.shared.paused();
|
||||
}
|
||||
state.player.audio_analysis = runtime.player.shared.audio_analysis();
|
||||
state.player.audio_analysis = if state.device_playback.is_control() {
|
||||
player::AudioAnalysisSnapshot::default()
|
||||
} else {
|
||||
runtime.player.shared.audio_analysis()
|
||||
};
|
||||
publish_playback_snapshot(state, runtime);
|
||||
}
|
||||
|
||||
fn unix_time_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn playback_repeat_to_wire(mode: state::RepeatMode) -> crate::devices::PlaybackRepeat {
|
||||
match mode {
|
||||
state::RepeatMode::Off => crate::devices::PlaybackRepeat::Off,
|
||||
state::RepeatMode::One => crate::devices::PlaybackRepeat::One,
|
||||
state::RepeatMode::All => crate::devices::PlaybackRepeat::All,
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_repeat_from_wire(mode: crate::devices::PlaybackRepeat) -> state::RepeatMode {
|
||||
match mode {
|
||||
crate::devices::PlaybackRepeat::Off => state::RepeatMode::Off,
|
||||
crate::devices::PlaybackRepeat::One => state::RepeatMode::One,
|
||||
crate::devices::PlaybackRepeat::All => state::RepeatMode::All,
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_state_from_ui(state: &AppState) -> crate::devices::PlaybackStateWire {
|
||||
crate::devices::PlaybackStateWire {
|
||||
queue: state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.map(crate::devices::PlaybackTrack::from_track)
|
||||
.collect(),
|
||||
queue_pos: state.player.queue_pos,
|
||||
playing: state.player.playing,
|
||||
paused: state.player.paused,
|
||||
position_secs: state.player.position_secs,
|
||||
volume: state.player.volume,
|
||||
shuffle: state.player.shuffle,
|
||||
repeat: playback_repeat_to_wire(state.player.repeat),
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_track_to_ui(
|
||||
wire: &crate::devices::PlaybackTrack,
|
||||
library: Option<&Library>,
|
||||
) -> crate::library::models::TrackItem {
|
||||
if let (Some(library), Some(content_id)) = (library, wire.content_id.as_deref())
|
||||
&& let Ok(Some(track)) = library.track_by_content_id(content_id)
|
||||
{
|
||||
return track;
|
||||
}
|
||||
wire.to_track_item()
|
||||
}
|
||||
|
||||
fn apply_playback_state_to_ui(
|
||||
state: &mut AppState,
|
||||
wire: &crate::devices::PlaybackStateWire,
|
||||
library: Option<&Library>,
|
||||
) {
|
||||
state.player.queue = wire
|
||||
.queue
|
||||
.iter()
|
||||
.map(|track| playback_track_to_ui(track, library))
|
||||
.collect();
|
||||
state.player.queue_pos = wire
|
||||
.queue_pos
|
||||
.min(state.player.queue.len().saturating_sub(1));
|
||||
state.player.playing = wire.playing && !state.player.queue.is_empty();
|
||||
state.player.paused = wire.paused;
|
||||
state.player.position_secs = wire.position_secs.max(0.0);
|
||||
state.player.volume = wire.volume.min(100);
|
||||
state.player.shuffle = wire.shuffle;
|
||||
state.player.repeat = playback_repeat_from_wire(wire.repeat);
|
||||
state.player.prefetched_pos = None;
|
||||
state.player.original_order = None;
|
||||
state.player.current = state
|
||||
.player
|
||||
.playing
|
||||
.then(|| state.player.queue.get(state.player.queue_pos).cloned())
|
||||
.flatten();
|
||||
if !state.player.playing {
|
||||
state.player.current = None;
|
||||
state.player.paused = false;
|
||||
}
|
||||
state.queue_tab.cursor = state
|
||||
.queue_tab
|
||||
.cursor
|
||||
.min(state.player.queue.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
fn publish_playback_snapshot(state: &mut AppState, runtime: &Runtime) {
|
||||
let Ok((device_id, device_name)) = runtime.devices.identity_summary() else {
|
||||
return;
|
||||
};
|
||||
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 {
|
||||
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,
|
||||
updated_at_ms: unix_time_ms(),
|
||||
state: playback_state_from_ui(state),
|
||||
};
|
||||
runtime.devices.publish_playback(snapshot);
|
||||
}
|
||||
|
||||
fn extrapolate_control_position(state: &mut AppState) {
|
||||
let Some(snapshot) = state.device_playback.last_remote_snapshot.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !snapshot.state.playing {
|
||||
return;
|
||||
}
|
||||
let elapsed = if snapshot.state.paused {
|
||||
0.0
|
||||
} else {
|
||||
(unix_time_ms().saturating_sub(snapshot.updated_at_ms) as f64 / 1000.0).max(0.0)
|
||||
};
|
||||
let duration = state
|
||||
.player
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|track| track.duration_seconds)
|
||||
.unwrap_or(0.0);
|
||||
let position = snapshot.state.position_secs + elapsed;
|
||||
state.player.position_secs = if duration > 0.0 {
|
||||
position.min(duration)
|
||||
} else {
|
||||
position
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn become_control_device(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
snapshot: crate::devices::PlaybackSnapshot,
|
||||
) {
|
||||
if state.device_playback.role == state::DevicePlaybackRole::Active {
|
||||
runtime.player.stop();
|
||||
}
|
||||
state.device_playback.role = state::DevicePlaybackRole::Control;
|
||||
state.device_playback.active_device_id = Some(snapshot.device_id.clone());
|
||||
state.device_playback.active_device_name = Some(snapshot.device_name.clone());
|
||||
state.device_playback.last_remote_snapshot = Some(snapshot.clone());
|
||||
state
|
||||
.device_playback
|
||||
.remote
|
||||
.insert(snapshot.device_id.clone(), snapshot.clone());
|
||||
apply_playback_state_to_ui(state, &snapshot.state, Some(runtime.library.as_ref()));
|
||||
extrapolate_control_position(state);
|
||||
state.status_message = Some(format!("controlling {}", snapshot.device_name));
|
||||
}
|
||||
|
||||
pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime, start_audio: bool) {
|
||||
let was_control = state.device_playback.role == state::DevicePlaybackRole::Control;
|
||||
state.device_playback.role = state::DevicePlaybackRole::Active;
|
||||
let Ok((device_id, device_name)) = runtime.devices.identity_summary() else {
|
||||
return;
|
||||
};
|
||||
state.device_playback.self_device_id = device_id.clone();
|
||||
state.device_playback.self_device_name = device_name.clone();
|
||||
state.device_playback.active_device_id = Some(device_id);
|
||||
state.device_playback.active_device_name = Some(device_name);
|
||||
state.device_playback.last_remote_snapshot = None;
|
||||
if was_control && start_audio && state.player.playing {
|
||||
start_current_audio(
|
||||
state,
|
||||
runtime,
|
||||
state.player.position_secs,
|
||||
state.player.paused,
|
||||
);
|
||||
}
|
||||
publish_playback_snapshot(state, runtime);
|
||||
}
|
||||
|
||||
fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) {
|
||||
if !state.device_playback.is_control() {
|
||||
return;
|
||||
}
|
||||
let Some(target) = state.device_playback.active_device_id.clone() else {
|
||||
return;
|
||||
};
|
||||
let command = crate::devices::PlaybackCommand::SetState {
|
||||
state: playback_state_from_ui(state),
|
||||
};
|
||||
if let Err(err) = runtime.devices.record_playback_command(&target, command) {
|
||||
tracing::warn!(%err, target, "recording playback command failed");
|
||||
state.status_message = Some(format!("device command failed: {err:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
const ARTISTS_PREFETCH_MARGIN: usize = 24;
|
||||
@@ -381,6 +588,10 @@ fn spawn_art_fetch(runtime: &Runtime, key: String, path: String, width: u16, hei
|
||||
|
||||
/// Execute a side effect requested by update().
|
||||
fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
if state.device_playback.is_control() && is_controlled_playback_effect(&effect) {
|
||||
perform_control_playback_effect(state, runtime, effect);
|
||||
return;
|
||||
}
|
||||
match effect {
|
||||
Effect::PlayCurrent => {
|
||||
play_current(state, runtime);
|
||||
@@ -412,6 +623,7 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
save_app_settings(state);
|
||||
}
|
||||
Effect::SetOptions => {}
|
||||
Effect::PlaybackQueueChanged => {}
|
||||
Effect::EnqueueRelease { id, next } => {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
@@ -688,6 +900,48 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_controlled_playback_effect(effect: &Effect) -> bool {
|
||||
matches!(
|
||||
effect,
|
||||
Effect::PlayCurrent
|
||||
| Effect::TogglePause
|
||||
| Effect::StopPlayback
|
||||
| Effect::SeekBy(_)
|
||||
| Effect::SetVolume(_)
|
||||
| Effect::SetOptions
|
||||
| Effect::RemoveQueueIndices { .. }
|
||||
| Effect::PlaybackQueueChanged
|
||||
)
|
||||
}
|
||||
|
||||
fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
match effect {
|
||||
Effect::PlayCurrent => {
|
||||
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
||||
state.player.playing = state.player.current.is_some();
|
||||
state.player.paused = false;
|
||||
state.player.position_secs = 0.0;
|
||||
}
|
||||
Effect::TogglePause => {}
|
||||
Effect::StopPlayback => {
|
||||
state.player.playing = false;
|
||||
state.player.current = None;
|
||||
state.player.paused = false;
|
||||
state.player.position_secs = 0.0;
|
||||
}
|
||||
Effect::SeekBy(delta) => {
|
||||
state.player.position_secs = (state.player.position_secs + delta as f64).max(0.0);
|
||||
}
|
||||
Effect::SetVolume(volume) => {
|
||||
state.player.volume = volume.min(100);
|
||||
save_app_settings(state);
|
||||
}
|
||||
Effect::SetOptions | Effect::RemoveQueueIndices { .. } | Effect::PlaybackQueueChanged => {}
|
||||
_ => {}
|
||||
}
|
||||
record_control_playback_state(state, runtime);
|
||||
}
|
||||
|
||||
fn clamp_settings_cursor(state: &mut AppState) {
|
||||
let last = state::settings_rows(state).len().saturating_sub(1);
|
||||
state.settings_cursor = state.settings_cursor.min(last);
|
||||
@@ -1375,6 +1629,97 @@ fn apply_queue_refresh(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_device_playback_snapshot(
|
||||
state: &mut AppState,
|
||||
runtime: &mut Runtime,
|
||||
snapshot: crate::devices::PlaybackSnapshot,
|
||||
) {
|
||||
if snapshot.device_id == state.device_playback.self_device_id {
|
||||
return;
|
||||
}
|
||||
state
|
||||
.device_playback
|
||||
.remote
|
||||
.insert(snapshot.device_id.clone(), snapshot.clone());
|
||||
let now = unix_time_ms();
|
||||
state.device_playback.online_devices = state
|
||||
.device_playback
|
||||
.remote
|
||||
.values()
|
||||
.filter(|snapshot| {
|
||||
now.saturating_sub(snapshot.updated_at_ms) <= state::DEVICE_ONLINE_TTL_MS
|
||||
})
|
||||
.count()
|
||||
+ 1;
|
||||
|
||||
if !snapshot.active {
|
||||
return;
|
||||
}
|
||||
if snapshot.state.playing {
|
||||
let was_active = state.device_playback.role == state::DevicePlaybackRole::Active;
|
||||
let was_paused = state.player.playing && state.player.paused;
|
||||
become_control_device(state, runtime, snapshot.clone());
|
||||
if was_active && was_paused {
|
||||
state.popup = Some(state::Popup::FedText {
|
||||
title: "Active device moved".to_string(),
|
||||
text: format!("Playback is now controlled by {}.", snapshot.device_name),
|
||||
});
|
||||
}
|
||||
} else if state.device_playback.is_control()
|
||||
&& state.device_playback.active_device_id.as_deref() == Some(snapshot.device_id.as_str())
|
||||
{
|
||||
become_active_device(state, runtime, false);
|
||||
state.status_message = Some("active playback moved to this device".into());
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_playback_command(
|
||||
state: &mut AppState,
|
||||
runtime: &mut Runtime,
|
||||
command: crate::devices::PlaybackCommand,
|
||||
) {
|
||||
match command {
|
||||
crate::devices::PlaybackCommand::SetState { state: wire } => {
|
||||
let old_current_id = state.player.current.as_ref().map(|track| track.id);
|
||||
let old_playing = state.player.playing;
|
||||
let old_paused = state.player.paused;
|
||||
become_active_device(state, runtime, false);
|
||||
apply_playback_state_to_ui(state, &wire, Some(runtime.library.as_ref()));
|
||||
runtime
|
||||
.player
|
||||
.set_volume(player::amplitude(state.player.volume));
|
||||
if !state.player.playing {
|
||||
runtime.player_start_pending = false;
|
||||
runtime.player.stop();
|
||||
push_media_update(state, runtime, true);
|
||||
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 {
|
||||
start_current_audio(
|
||||
state,
|
||||
runtime,
|
||||
state.player.position_secs,
|
||||
state.player.paused,
|
||||
);
|
||||
push_media_metadata(state, runtime);
|
||||
} else {
|
||||
runtime.player.seek(std::time::Duration::from_secs_f64(
|
||||
state.player.position_secs,
|
||||
));
|
||||
if state.player.paused && !old_paused {
|
||||
runtime.player.pause();
|
||||
} else if !state.player.paused && old_paused {
|
||||
runtime.player.resume();
|
||||
}
|
||||
}
|
||||
push_media_update(state, runtime, true);
|
||||
publish_playback_snapshot(state, runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::StatusMessage(message) => state.status_message = Some(message),
|
||||
@@ -1383,6 +1728,45 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
AppEvent::DeviceSyncStatus(status) => {
|
||||
state.federation.devices = Some(status);
|
||||
let now = unix_time_ms();
|
||||
state.device_playback.online_devices = state
|
||||
.federation
|
||||
.devices
|
||||
.as_ref()
|
||||
.map(|status| {
|
||||
status
|
||||
.devices
|
||||
.iter()
|
||||
.filter(|device| {
|
||||
state::device_presence_section(state, device, now)
|
||||
== state::DevicePresenceSection::Online
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let active_revoked = state.device_playback.is_control()
|
||||
&& state
|
||||
.device_playback
|
||||
.active_device_id
|
||||
.as_ref()
|
||||
.is_some_and(|active| {
|
||||
state
|
||||
.federation
|
||||
.devices
|
||||
.as_ref()
|
||||
.and_then(|status| {
|
||||
status
|
||||
.devices
|
||||
.iter()
|
||||
.find(|device| device.device_id == *active)
|
||||
})
|
||||
.is_some_and(|device| device.revoked)
|
||||
});
|
||||
if active_revoked {
|
||||
become_active_device(state, runtime, false);
|
||||
state.status_message = Some("active playback moved to this device".into());
|
||||
}
|
||||
clamp_settings_cursor(state);
|
||||
}
|
||||
AppEvent::DeviceInvite(result) => match result {
|
||||
@@ -1415,6 +1799,12 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
});
|
||||
state.federation.devices = Some(runtime.devices.status());
|
||||
}
|
||||
AppEvent::DevicePlayback(snapshot) => {
|
||||
handle_device_playback_snapshot(state, runtime, snapshot);
|
||||
}
|
||||
AppEvent::PlaybackCommand(command) => {
|
||||
handle_playback_command(state, runtime, command);
|
||||
}
|
||||
AppEvent::FedSearchLoaded { seq, result } => {
|
||||
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||
return;
|
||||
@@ -1769,6 +2159,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
AppEvent::EnqueueTracks { tracks, next } => {
|
||||
let count = tracks.len();
|
||||
update::enqueue_tracks(state, tracks, next);
|
||||
record_control_playback_state(state, runtime);
|
||||
state.status_message = Some(if next {
|
||||
format!("{count} tracks queued next")
|
||||
} else {
|
||||
@@ -1849,8 +2240,12 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
state.player.playing = false;
|
||||
state.player.paused = false;
|
||||
state.player.current = None;
|
||||
runtime.player.stop();
|
||||
push_media_update(state, runtime, true);
|
||||
if state.device_playback.is_control() {
|
||||
record_control_playback_state(state, runtime);
|
||||
} else {
|
||||
runtime.player.stop();
|
||||
push_media_update(state, runtime, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
+123
-2
@@ -12,11 +12,89 @@ use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crate::app::Runtime;
|
||||
use crate::app::event::AppEvent;
|
||||
use crate::app::state::{
|
||||
AppState, DeleteTarget, EditField, EditTarget, FedInputField, Loadable, Popup,
|
||||
addable_playlists,
|
||||
self, AppState, DeleteTarget, DevicePresenceSection, EditField, EditTarget, FedInputField,
|
||||
Loadable, Popup, addable_playlists,
|
||||
};
|
||||
use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ConnectedDevicePopupRow {
|
||||
pub device_id: String,
|
||||
pub name: String,
|
||||
pub is_self: bool,
|
||||
pub online: bool,
|
||||
pub revoked: bool,
|
||||
pub section: DevicePresenceSection,
|
||||
pub active: bool,
|
||||
pub playing: bool,
|
||||
pub paused: bool,
|
||||
pub queue_len: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn connected_device_rows(state: &AppState) -> Vec<ConnectedDevicePopupRow> {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(status) = &state.federation.devices {
|
||||
let now = state::unix_time_ms();
|
||||
for index in state::device_status_order(state) {
|
||||
let Some(device) = status.devices.get(index) else {
|
||||
continue;
|
||||
};
|
||||
let snapshot = state.device_playback.remote.get(&device.device_id);
|
||||
let is_self =
|
||||
device.is_self || device.device_id == state.device_playback.self_device_id;
|
||||
let section = state::device_presence_section(state, device, now);
|
||||
let online = section == DevicePresenceSection::Online;
|
||||
rows.push(ConnectedDevicePopupRow {
|
||||
device_id: device.device_id.clone(),
|
||||
name: state::device_display_name(device),
|
||||
is_self,
|
||||
online,
|
||||
revoked: device.revoked,
|
||||
section,
|
||||
active: state::device_status_active(state, &device.device_id),
|
||||
playing: if is_self {
|
||||
state.player.playing
|
||||
} else {
|
||||
snapshot.is_some_and(|snapshot| snapshot.state.playing)
|
||||
},
|
||||
paused: if is_self {
|
||||
state.player.paused
|
||||
} else {
|
||||
snapshot.is_some_and(|snapshot| snapshot.state.paused)
|
||||
},
|
||||
queue_len: if is_self {
|
||||
state.player.queue.len()
|
||||
} else {
|
||||
snapshot
|
||||
.map(|snapshot| snapshot.state.queue.len())
|
||||
.unwrap_or(0)
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if rows
|
||||
.iter()
|
||||
.all(|row| row.device_id != state.device_playback.self_device_id)
|
||||
{
|
||||
rows.insert(
|
||||
0,
|
||||
ConnectedDevicePopupRow {
|
||||
device_id: state.device_playback.self_device_id.clone(),
|
||||
name: state.device_playback.self_device_name.clone(),
|
||||
is_self: true,
|
||||
online: true,
|
||||
revoked: false,
|
||||
section: DevicePresenceSection::Online,
|
||||
active: state.device_playback.role == crate::app::state::DevicePlaybackRole::Active,
|
||||
playing: state.player.playing,
|
||||
paused: state.player.paused,
|
||||
queue_len: state.player.queue.len(),
|
||||
},
|
||||
);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
let Some(popup) = state.popup.take() else {
|
||||
return;
|
||||
@@ -82,6 +160,49 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
Popup::ConfirmDeviceRevoke { device_id, name } => {
|
||||
handle_device_revoke(state, runtime, device_id, name, key);
|
||||
}
|
||||
Popup::ConnectedDevices { cursor } => {
|
||||
handle_connected_devices(state, runtime, cursor, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_connected_devices(
|
||||
state: &mut AppState,
|
||||
runtime: &mut Runtime,
|
||||
cursor: usize,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
let rows = connected_device_rows(state);
|
||||
let last = rows.len().saturating_sub(1);
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
state.popup = Some(Popup::ConnectedDevices {
|
||||
cursor: cursor.saturating_sub(1),
|
||||
});
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
state.popup = Some(Popup::ConnectedDevices {
|
||||
cursor: (cursor + 1).min(last),
|
||||
});
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(row) = rows.get(cursor.min(last)) {
|
||||
if row.revoked {
|
||||
state.status_message = Some("revoked device cannot be controlled".into());
|
||||
} else if row.is_self {
|
||||
super::become_active_device(state, runtime, true);
|
||||
state.status_message = Some("active playback moved to this device".into());
|
||||
} else if let Some(snapshot) =
|
||||
state.device_playback.remote.get(&row.device_id).cloned()
|
||||
{
|
||||
super::become_control_device(state, runtime, snapshot);
|
||||
} else {
|
||||
state.status_message = Some("device has no playback snapshot yet".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => state.popup = Some(Popup::ConnectedDevices { cursor }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+172
-4
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app::input::LineEdit;
|
||||
@@ -542,6 +542,8 @@ pub enum Popup {
|
||||
},
|
||||
/// Confirmation before revoking a trusted device.
|
||||
ConfirmDeviceRevoke { device_id: String, name: String },
|
||||
/// Connected playback devices and their current role/status.
|
||||
ConnectedDevices { cursor: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -602,6 +604,128 @@ pub enum SettingsRow {
|
||||
VisualizationEdit,
|
||||
}
|
||||
|
||||
pub const DEVICE_ONLINE_TTL_MS: i64 = 45_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DevicePresenceSection {
|
||||
Online,
|
||||
Offline,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl DevicePresenceSection {
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
DevicePresenceSection::Online => "Online devices",
|
||||
DevicePresenceSection::Offline => "Offline devices",
|
||||
DevicePresenceSection::Revoked => "Revoked devices",
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_rank(self) -> u8 {
|
||||
match self {
|
||||
DevicePresenceSection::Online => 0,
|
||||
DevicePresenceSection::Offline => 1,
|
||||
DevicePresenceSection::Revoked => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unix_time_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn device_display_name(device: &crate::devices::DeviceStatusRow) -> String {
|
||||
if device.name.trim().is_empty() {
|
||||
device.device_id.chars().take(10).collect()
|
||||
} else {
|
||||
device.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn device_status_active(state: &AppState, device_id: &str) -> bool {
|
||||
state
|
||||
.device_playback
|
||||
.active_device_id
|
||||
.as_deref()
|
||||
.is_some_and(|active| active == device_id)
|
||||
|| state
|
||||
.device_playback
|
||||
.remote
|
||||
.get(device_id)
|
||||
.is_some_and(|snapshot| snapshot.active)
|
||||
}
|
||||
|
||||
pub fn device_status_online(
|
||||
state: &AppState,
|
||||
device: &crate::devices::DeviceStatusRow,
|
||||
now_ms: i64,
|
||||
) -> bool {
|
||||
device.is_self
|
||||
|| device.device_id == state.device_playback.self_device_id
|
||||
|| device
|
||||
.last_seen_ms
|
||||
.is_some_and(|seen| now_ms.saturating_sub(seen) <= DEVICE_ONLINE_TTL_MS)
|
||||
|| state
|
||||
.device_playback
|
||||
.remote
|
||||
.get(&device.device_id)
|
||||
.is_some_and(|snapshot| {
|
||||
now_ms.saturating_sub(snapshot.updated_at_ms) <= DEVICE_ONLINE_TTL_MS
|
||||
})
|
||||
}
|
||||
|
||||
pub fn device_presence_section(
|
||||
state: &AppState,
|
||||
device: &crate::devices::DeviceStatusRow,
|
||||
now_ms: i64,
|
||||
) -> DevicePresenceSection {
|
||||
if device.revoked {
|
||||
DevicePresenceSection::Revoked
|
||||
} else if device_status_online(state, device, now_ms) {
|
||||
DevicePresenceSection::Online
|
||||
} else {
|
||||
DevicePresenceSection::Offline
|
||||
}
|
||||
}
|
||||
|
||||
pub fn device_status_order(state: &AppState) -> Vec<usize> {
|
||||
let Some(status) = &state.federation.devices else {
|
||||
return Vec::new();
|
||||
};
|
||||
let now = unix_time_ms();
|
||||
let mut indices: Vec<usize> = status
|
||||
.devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, device)| (!device.revoked).then_some(index))
|
||||
.collect();
|
||||
indices.sort_by(|left, right| {
|
||||
let left_device = &status.devices[*left];
|
||||
let right_device = &status.devices[*right];
|
||||
let left_section = device_presence_section(state, left_device, now).sort_rank();
|
||||
let right_section = device_presence_section(state, right_device, now).sort_rank();
|
||||
(
|
||||
left_section,
|
||||
!device_status_active(state, &left_device.device_id),
|
||||
!left_device.is_self,
|
||||
device_display_name(left_device).to_ascii_lowercase(),
|
||||
left_device.device_id.as_str(),
|
||||
)
|
||||
.cmp(&(
|
||||
right_section,
|
||||
!device_status_active(state, &right_device.device_id),
|
||||
!right_device.is_self,
|
||||
device_display_name(right_device).to_ascii_lowercase(),
|
||||
right_device.device_id.as_str(),
|
||||
))
|
||||
});
|
||||
indices
|
||||
}
|
||||
|
||||
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||
let mut rows = Vec::new();
|
||||
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
||||
@@ -609,9 +733,11 @@ pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||
rows.push(SettingsRow::DeviceInvite);
|
||||
rows.push(SettingsRow::DeviceConnect);
|
||||
rows.push(SettingsRow::DeviceSyncNow);
|
||||
if let Some(status) = &state.federation.devices {
|
||||
rows.extend((0..status.devices.len()).map(SettingsRow::Device));
|
||||
}
|
||||
rows.extend(
|
||||
device_status_order(state)
|
||||
.into_iter()
|
||||
.map(SettingsRow::Device),
|
||||
);
|
||||
rows.push(SettingsRow::VisualizationClock);
|
||||
rows.extend(
|
||||
state
|
||||
@@ -800,6 +926,47 @@ impl Default for PlayerBar {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DevicePlaybackRole {
|
||||
#[default]
|
||||
Active,
|
||||
Control,
|
||||
}
|
||||
|
||||
impl DevicePlaybackRole {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
DevicePlaybackRole::Active => "active",
|
||||
DevicePlaybackRole::Control => "control",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DevicePlaybackState {
|
||||
pub role: DevicePlaybackRole,
|
||||
pub self_device_id: String,
|
||||
pub self_device_name: String,
|
||||
pub active_device_id: Option<String>,
|
||||
pub active_device_name: Option<String>,
|
||||
pub online_devices: usize,
|
||||
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
|
||||
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
|
||||
}
|
||||
|
||||
impl DevicePlaybackState {
|
||||
pub fn is_control(&self) -> bool {
|
||||
self.role == DevicePlaybackRole::Control
|
||||
}
|
||||
|
||||
pub fn active_label(&self) -> String {
|
||||
self.active_device_name
|
||||
.clone()
|
||||
.or_else(|| self.active_device_id.clone())
|
||||
.unwrap_or_else(|| "this device".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Single source of truth for the UI. Mutated only by `update()` and the
|
||||
/// event handlers in the main loop; views render from `&AppState`.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -814,6 +981,7 @@ pub struct AppState {
|
||||
pub status_message: Option<String>,
|
||||
pub settings_cursor: usize,
|
||||
pub player: PlayerBar,
|
||||
pub device_playback: DevicePlaybackState,
|
||||
pub visualizer: crate::visualizer::VisualizerState,
|
||||
pub global: GlobalTab,
|
||||
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
||||
|
||||
+7
-1
@@ -44,6 +44,8 @@ pub enum Effect {
|
||||
restart_paused: Option<bool>,
|
||||
stop: bool,
|
||||
},
|
||||
/// Queue/options changed without a direct audio engine action.
|
||||
PlaybackQueueChanged,
|
||||
/// Persist the federation settings and start/stop the node.
|
||||
FedApplySettings,
|
||||
/// Force an immediate library publish into the DHT.
|
||||
@@ -102,6 +104,10 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
state.help_visible = !state.help_visible;
|
||||
None
|
||||
}
|
||||
Action::OpenConnectedDevices => {
|
||||
state.popup = Some(super::state::Popup::ConnectedDevices { cursor: 0 });
|
||||
None
|
||||
}
|
||||
Action::NextTab => {
|
||||
switch_tab(state, state.active_tab.next());
|
||||
None
|
||||
@@ -642,7 +648,7 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
||||
}
|
||||
}
|
||||
if state.active_tab != Tab::Global {
|
||||
return None;
|
||||
return Some(Effect::PlaybackQueueChanged);
|
||||
}
|
||||
if state.global.stack.is_empty() {
|
||||
let artist = state.global.artists.get(state.global.selected).cloned()?;
|
||||
|
||||
Reference in New Issue
Block a user