From cddebfd9ad52b8ffacdf878178c8554a83ab409a Mon Sep 17 00:00:00 2001 From: Vlad S Date: Tue, 16 Jun 2026 20:42:18 +0200 Subject: [PATCH] fix native startup device activation and idle playbar state --- CHANGELOG.md | 6 + src/core/app.rs | 62 ++++++ src/infra/network/playback.rs | 395 +++++++++++++++++++++++++++++----- src/tui/runner.rs | 2 +- src/tui/ui/player.rs | 114 +++++++++- 5 files changed, 514 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f94b3..34af870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- **Native streaming startup device recovery**: Startup now trusts the local `spotatui` Connect device id, preserves saved external devices, activates the native device when Spotify reports no active playback, and renders an actionable idle playbar so paused native sessions restore without manually opening the device selector ([#301](https://github.com/LargeModGames/spotatui/issues/301)). + ## [v0.39.1] 2026-06-12 ### Fixed diff --git a/src/core/app.rs b/src/core/app.rs index b05f928..7e70f10 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -35,7 +35,16 @@ use std::{ }; use arboard::Clipboard; +#[cfg(feature = "streaming")] +use chrono::Utc; use log::info; +#[cfg(feature = "streaming")] +use rspotify::model::{ + context::Actions, + device::Device, + enums::{CurrentlyPlayingType, RepeatState}, + DeviceType, +}; pub const LIBRARY_OPTIONS: [&str; 7] = [ "Discover", @@ -2172,6 +2181,49 @@ impl App { self.api_error = e.to_string(); } + #[cfg(feature = "streaming")] + pub fn mark_native_streaming_device_available( + &mut self, + device_id: String, + device_name: String, + volume_percent: u8, + ) { + self.native_device_id = Some(device_id.clone()); + self.is_streaming_active = true; + self.native_activation_pending = false; + self.native_is_playing = Some(false); + + if self + .current_playback_context + .as_ref() + .and_then(|ctx| ctx.item.as_ref()) + .is_some() + { + return; + } + + self.current_playback_context = Some(CurrentPlaybackContext { + device: Device { + id: Some(device_id), + is_active: true, + is_private_session: false, + is_restricted: false, + name: device_name, + _type: DeviceType::Computer, + volume_percent: Some(u32::from(volume_percent)), + }, + repeat_state: RepeatState::Off, + shuffle_state: self.user_config.behavior.shuffle_enabled, + context: None, + timestamp: Utc::now(), + progress: None, + is_playing: false, + item: None, + currently_playing_type: CurrentlyPlayingType::Unknown, + actions: Actions::default(), + }); + } + #[cfg(feature = "streaming")] pub fn has_fresh_native_activity(&self) -> bool { self.native_track_info.is_some() @@ -2235,6 +2287,16 @@ impl App { // Use native streaming player for instant control (bypasses event channel latency) #[cfg(feature = "streaming")] if self.is_native_streaming_active_for_playback() { + if self + .current_playback_context + .as_ref() + .and_then(|ctx| ctx.item.as_ref()) + .is_none() + { + self.dispatch(IoEvent::StartPlayback(None, None, None)); + return; + } + if let Some(ref player) = self.streaming_player { let is_playing = self .native_is_playing diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index 18f9fee..52ae859 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -1,6 +1,9 @@ use super::{IoEvent, Network}; #[cfg(feature = "streaming")] -use crate::core::app::NativePlaybackOrigin; +use crate::core::{ + app::{App, NativePlaybackOrigin}, + config::ClientConfig, +}; use crate::tui::ui::util::create_artist_string; use anyhow::anyhow; use chrono::TimeDelta; @@ -150,6 +153,13 @@ struct NativeActivationContext { saved_external_confirmed_available: bool, } +#[cfg(feature = "streaming")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NativeDevicePreferenceUpdate { + Persist, + KeepExistingPreference, +} + #[cfg(feature = "streaming")] fn should_activate_native_for_playback(context: NativeActivationContext) -> bool { if !context.player_connected { @@ -173,6 +183,116 @@ fn should_activate_native_for_playback(context: NativeActivationContext) -> bool !context.saved_external_confirmed_available } +#[cfg(feature = "streaming")] +fn native_device_preference_update( + saved_device_id: Option<&str>, + explicit_persist: bool, + saved_device_matches_native: bool, +) -> NativeDevicePreferenceUpdate { + if explicit_persist || saved_device_id.is_none() || saved_device_matches_native { + NativeDevicePreferenceUpdate::Persist + } else { + NativeDevicePreferenceUpdate::KeepExistingPreference + } +} + +#[cfg(feature = "streaming")] +fn saved_device_matches_native_player( + saved_device_id: Option<&str>, + native_device_id: Option<&str>, + devices: Option<&DevicePayload>, + native_device_name: &str, +) -> bool { + saved_device_id.is_some_and(|saved| { + native_device_id == Some(saved) + || devices.is_some_and(|payload| { + payload.devices.iter().any(|device| { + device.id.as_deref() == Some(saved) + && device.name.eq_ignore_ascii_case(native_device_name) + }) + }) + }) +} + +#[cfg(feature = "streaming")] +fn persist_native_device_id_if_needed( + client_config: &mut ClientConfig, + app: &mut App, + native_device_id: &str, + update: NativeDevicePreferenceUpdate, +) { + if update == NativeDevicePreferenceUpdate::KeepExistingPreference { + return; + } + + if client_config.device_id.as_deref() == Some(native_device_id) { + return; + } + + if let Err(e) = client_config.set_device_id(native_device_id.to_string()) { + app.handle_error(anyhow!(e)); + } +} + +#[cfg(feature = "streaming")] +fn mark_native_idle_device_if_preferred( + client_config: &mut ClientConfig, + app: &mut App, + player: &crate::infra::player::StreamingPlayer, +) -> bool { + if !player.is_connected() { + return false; + } + + let native_device_id = player.device_id(); + let saved_device_matches_native = saved_device_matches_native_player( + client_config.device_id.as_deref(), + Some(&native_device_id), + app.devices.as_ref(), + player.device_name(), + ); + let native_preference_update = native_device_preference_update( + client_config.device_id.as_deref(), + false, + saved_device_matches_native, + ); + + if native_preference_update == NativeDevicePreferenceUpdate::KeepExistingPreference { + return false; + } + + let should_transfer = app + .last_device_activation + .is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5)); + if should_transfer { + let _ = player.transfer(None); + player.activate(); + app.last_device_activation = Some(Instant::now()); + } + + app.mark_native_streaming_device_available( + native_device_id.clone(), + player.device_name().to_string(), + player.get_volume(), + ); + persist_native_device_id_if_needed( + client_config, + app, + &native_device_id, + native_preference_update, + ); + + should_transfer +} + +#[cfg(feature = "streaming")] +fn spotify_payload_confirms_native_device(payload: &DevicePayload, native_device_id: &str) -> bool { + payload + .devices + .iter() + .any(|device| device.id.as_deref() == Some(native_device_id)) +} + #[cfg(feature = "streaming")] fn is_no_active_device_error(e: &anyhow::Error) -> bool { let text = e.to_string().to_ascii_lowercase(); @@ -305,14 +425,12 @@ async fn should_activate_native_streaming_for_playback(network: &Network) -> boo current_device.is_some_and(|device| device.name.eq_ignore_ascii_case(native_name)); let native_has_fresh_activity = app.has_fresh_native_activity(); - let saved_device_matches_native = saved_device_id.is_some_and(|saved| { - native_device_id == Some(saved) - || app.devices.as_ref().is_some_and(|payload| { - payload.devices.iter().any(|device| { - device.id.as_deref() == Some(saved) && device.name.eq_ignore_ascii_case(native_name) - }) - }) - }); + let saved_device_matches_native = saved_device_matches_native_player( + saved_device_id, + native_device_id, + app.devices.as_ref(), + native_name, + ); let saved_external_confirmed_available = saved_device_id.is_some_and(|saved| { app.devices.as_ref().is_some_and(|payload| { @@ -663,7 +781,19 @@ impl PlaybackNetwork for Network { } } Ok(None) => { - app.instant_since_last_current_playback_poll = Instant::now(); + #[cfg(feature = "streaming")] + let native_transfer_requested = streaming_player.as_ref().is_some_and(|player| { + mark_native_idle_device_if_preferred(&mut self.client_config, &mut app, player) + }); + #[cfg(not(feature = "streaming"))] + let native_transfer_requested = false; + + if native_transfer_requested { + app.dispatch(IoEvent::GetCurrentPlayback); + app.instant_since_last_current_playback_poll = Instant::now() - Duration::from_secs(6); + } else { + app.instant_since_last_current_playback_poll = Instant::now(); + } } Err(e) => { app.is_fetching_current_playback = false; @@ -720,7 +850,19 @@ impl PlaybackNetwork for Network { // 404 = no active device/player; treat as idle, not an error if err.to_string().contains("404") || err.to_string().contains("Not Found") { app.current_playback_context = None; - app.instant_since_last_current_playback_poll = Instant::now(); + #[cfg(feature = "streaming")] + let native_transfer_requested = streaming_player.as_ref().is_some_and(|player| { + mark_native_idle_device_if_preferred(&mut self.client_config, &mut app, player) + }); + #[cfg(not(feature = "streaming"))] + let native_transfer_requested = false; + + if native_transfer_requested { + app.dispatch(IoEvent::GetCurrentPlayback); + app.instant_since_last_current_playback_poll = Instant::now() - Duration::from_secs(6); + } else { + app.instant_since_last_current_playback_poll = Instant::now(); + } app.is_fetching_current_playback = false; return; } @@ -773,19 +915,34 @@ impl PlaybackNetwork for Network { { if let Some(player) = current_streaming_player(self).await { let requested_origin = requested_native_playback_origin(self, &context_id, &uris).await; - let native_route = resolve_native_playback_route(self, &context_id).await; let activation_time = Instant::now(); - let should_transfer = { + let native_device_id = player.device_id(); + let (should_transfer, native_preference_update) = { let app = self.app.lock().await; + let saved_device_matches_native = saved_device_matches_native_player( + self.client_config.device_id.as_deref(), + Some(&native_device_id), + app.devices.as_ref(), + player.device_name(), + ); let activation_pending = app.native_activation_pending; let recent_activation = app .last_device_activation .is_some_and(|instant| instant.elapsed() < Duration::from_secs(5)); - if activation_pending { + let should_transfer = if activation_pending { !recent_activation } else { !app.is_streaming_active && !recent_activation - } + }; + + ( + should_transfer, + native_device_preference_update( + self.client_config.device_id.as_deref(), + false, + saved_device_matches_native, + ), + ) }; if should_transfer { @@ -799,19 +956,21 @@ impl PlaybackNetwork for Network { app.last_device_activation = Some(activation_time); app.native_activation_pending = false; app.native_playback_origin = Some(requested_origin); + app.native_device_id = Some(native_device_id.clone()); + persist_native_device_id_if_needed( + &mut self.client_config, + &mut app, + &native_device_id, + native_preference_update, + ); } + let native_route = resolve_native_playback_route(self, &context_id).await; // For resume playback (no context, no uris) if context_id.is_none() && uris.is_none() { - let (can_resume_direct_native, native_device_id) = { + let can_resume_direct_native = { let app = self.app.lock().await; - ( - app.native_track_info.is_some() || app.last_track_id.is_some(), - app - .native_device_id - .clone() - .or_else(|| Some(player.device_id())), - ) + app.native_track_info.is_some() || app.last_track_id.is_some() }; if can_resume_direct_native { @@ -821,23 +980,23 @@ impl PlaybackNetwork for Network { if let Some(ctx) = &mut app.current_playback_context { ctx.is_playing = true; } - } else if let Some(device_id) = native_device_id { + } else { info!( "starting native resume playback via Spotify API on device {}", - device_id + native_device_id ); match self .spotify_api_request_json( Method::PUT, "me/player/play", - &[("device_id", device_id.clone())], + &[("device_id", native_device_id.clone())], None, ) .await { Ok(_) => { let mut app = self.app.lock().await; - app.native_device_id = Some(device_id); + app.native_device_id = Some(native_device_id); if let Some(ctx) = &mut app.current_playback_context { ctx.is_playing = true; } @@ -852,12 +1011,6 @@ impl PlaybackNetwork for Network { info!("native resume via Spotify API failed: {}", e); } } - } else { - let mut app = self.app.lock().await; - app.set_status_message( - format!("No playback to resume on {}.", player.device_name()), - 4, - ); } return; } @@ -977,6 +1130,17 @@ impl PlaybackNetwork for Network { player.activate(); { let mut app = self.app.lock().await; + let saved_device_matches_native = saved_device_matches_native_player( + self.client_config.device_id.as_deref(), + Some(&native_device_id), + app.devices.as_ref(), + player.device_name(), + ); + let native_preference_update = native_device_preference_update( + self.client_config.device_id.as_deref(), + false, + saved_device_matches_native, + ); app.is_streaming_active = true; app.native_activation_pending = false; app.native_playback_origin = Some(requested_origin); @@ -984,6 +1148,12 @@ impl PlaybackNetwork for Network { app.last_device_activation = Some(activation_time); app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6); + persist_native_device_id_if_needed( + &mut self.client_config, + &mut app, + &native_device_id, + native_preference_update, + ); } if let Some(request) = native_load_request(context_id, uris, offset) { @@ -1312,13 +1482,25 @@ impl PlaybackNetwork for Network { if is_native_transfer { if let Some(ref player) = streaming_player { + let native_device_id = player.device_id(); let _ = player.transfer(None); player.activate(); let mut app = self.app.lock().await; + let saved_device_matches_native = saved_device_matches_native_player( + self.client_config.device_id.as_deref(), + Some(&native_device_id), + app.devices.as_ref(), + player.device_name(), + ); + let native_preference_update = native_device_preference_update( + self.client_config.device_id.as_deref(), + persist_device_id, + saved_device_matches_native, + ); app.is_streaming_active = true; app.native_activation_pending = true; app.native_playback_origin = None; - app.native_device_id = Some(device_id.clone()); + app.native_device_id = Some(native_device_id.clone()); // Drop the stale previous-device context so playback routing follows the // native intent (is_streaming_active) until the next poll repopulates it // — mirrors the non-native transfer branch below. Without this, the first @@ -1326,11 +1508,12 @@ impl PlaybackNetwork for Network { app.current_playback_context = None; app.last_device_activation = Some(Instant::now()); app.instant_since_last_current_playback_poll = Instant::now() - Duration::from_secs(6); - if persist_device_id { - if let Err(e) = self.client_config.set_device_id(device_id) { - app.handle_error(anyhow!(e)); - } - } + persist_native_device_id_if_needed( + &mut self.client_config, + &mut app, + &native_device_id, + native_preference_update, + ); return; } } @@ -1371,16 +1554,28 @@ impl PlaybackNetwork for Network { #[cfg(feature = "streaming")] async fn auto_select_streaming_device(&mut self, device_name: String, persist_device_id: bool) { - tokio::time::sleep(Duration::from_millis(200)).await; - if let Some(player) = current_streaming_player(self).await { let activation_time = Instant::now(); - let should_transfer = { + let native_device_id = player.device_id(); + let (should_transfer, native_preference_update) = { let app = self.app.lock().await; + let saved_device_matches_native = saved_device_matches_native_player( + self.client_config.device_id.as_deref(), + Some(&native_device_id), + app.devices.as_ref(), + player.device_name(), + ); let recent_activation = app .last_device_activation .is_some_and(|instant| instant.elapsed() < Duration::from_secs(5)); - !app.native_activation_pending && !app.is_streaming_active && !recent_activation + ( + !app.native_activation_pending && !app.is_streaming_active && !recent_activation, + native_device_preference_update( + self.client_config.device_id.as_deref(), + persist_device_id, + saved_device_matches_native, + ), + ) }; { @@ -1400,33 +1595,39 @@ impl PlaybackNetwork for Network { let mut app = self.app.lock().await; app.is_streaming_active = true; app.native_activation_pending = false; + app.native_device_id = Some(native_device_id.clone()); app.last_device_activation = Some(activation_time); app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6); + persist_native_device_id_if_needed( + &mut self.client_config, + &mut app, + &native_device_id, + native_preference_update, + ); } - for attempt in 0..2 { - if attempt > 0 { - tokio::time::sleep(Duration::from_millis(200)).await; - } + for _ in 0..2 { + tokio::time::sleep(Duration::from_millis(200)).await; match self .spotify_get_typed::("me/player/devices", &[]) .await { Ok(payload) => { - if let Some(device) = payload + let native_confirmed = + spotify_payload_confirms_native_device(&payload, &native_device_id); + let name_seen = payload .devices .iter() - .find(|d| d.name.to_lowercase() == device_name.to_lowercase()) - { - if let Some(id) = &device.id { - if persist_device_id { - let _ = self.client_config.set_device_id(id.clone()); - } - let mut app = self.app.lock().await; - app.native_device_id = Some(id.clone()); - return; - } + .any(|device| device.name.eq_ignore_ascii_case(&device_name)); + + if native_confirmed || name_seen { + let mut app = self.app.lock().await; + app.devices = Some(payload); + } + + if native_confirmed { + return; } } Err(_) => continue, @@ -1521,6 +1722,8 @@ mod tests { use rspotify::model::{ artist::SimplifiedArtist, idtypes::TrackId, track::FullTrack, SimplifiedAlbum, }; + #[cfg(feature = "streaming")] + use rspotify::model::{device::Device, DeviceType}; use rspotify::prelude::Id; use std::collections::HashMap; @@ -1559,6 +1762,20 @@ mod tests { }) } + #[cfg(feature = "streaming")] + #[allow(deprecated)] + fn playback_device(id: &str, name: &str) -> Device { + Device { + id: Some(id.to_string()), + is_active: false, + is_private_session: false, + is_restricted: false, + name: name.to_string(), + _type: DeviceType::Computer, + volume_percent: Some(50), + } + } + #[test] fn trim_api_playback_uris_leaves_small_requests_unchanged() { let uris = vec![ @@ -1683,6 +1900,68 @@ mod tests { ))); } + #[cfg(feature = "streaming")] + #[test] + fn native_device_preference_update_persists_when_no_saved_device() { + assert_eq!( + native_device_preference_update(None, false, false), + NativeDevicePreferenceUpdate::Persist + ); + } + + #[cfg(feature = "streaming")] + #[test] + fn native_device_preference_update_persists_when_explicitly_requested() { + assert_eq!( + native_device_preference_update(Some("phone-device"), true, false), + NativeDevicePreferenceUpdate::Persist + ); + } + + #[cfg(feature = "streaming")] + #[test] + fn native_device_preference_update_keeps_existing_saved_device_for_fallback() { + assert_eq!( + native_device_preference_update(Some("phone-device"), false, false), + NativeDevicePreferenceUpdate::KeepExistingPreference + ); + } + + #[cfg(feature = "streaming")] + #[test] + fn native_device_preference_update_refreshes_saved_native_device() { + assert_eq!( + native_device_preference_update(Some("old-native-device"), false, true), + NativeDevicePreferenceUpdate::Persist + ); + } + + #[cfg(feature = "streaming")] + #[test] + fn spotify_payload_confirms_native_device_by_id() { + let payload = DevicePayload { + devices: vec![playback_device("native-device", "spotatui")], + }; + + assert!(spotify_payload_confirms_native_device( + &payload, + "native-device" + )); + } + + #[cfg(feature = "streaming")] + #[test] + fn spotify_payload_does_not_confirm_stale_native_name_with_different_id() { + let payload = DevicePayload { + devices: vec![playback_device("stale-device", "spotatui")], + }; + + assert!(!spotify_payload_confirms_native_device( + &payload, + "native-device" + )); + } + #[cfg(feature = "streaming")] #[test] fn native_activation_uses_native_when_no_current_device_or_saved_device() { diff --git a/src/tui/runner.rs b/src/tui/runner.rs index 097da1a..3f03677 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -742,9 +742,9 @@ pub async fn start_ui( if is_first_render { let mut app = app.lock().await; + app.dispatch(IoEvent::GetCurrentPlayback); app.dispatch(IoEvent::GetPlaylists); app.dispatch(IoEvent::GetUser); - app.dispatch(IoEvent::GetCurrentPlayback); if app.user_config.behavior.enable_global_song_count { app.dispatch(IoEvent::FetchGlobalSongCount); } diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index bd2f0d1..86d7308 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -370,12 +370,7 @@ pub(crate) fn playbar_control_hitboxes( app: &App, playbar_area: Rect, ) -> Vec<(PlaybarControl, Rect)> { - if app - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.item.as_ref()) - .is_none() - { + if !playbar_controls_available(app) { return Vec::new(); } @@ -386,6 +381,12 @@ pub(crate) fn playbar_control_hitboxes( .collect() } +fn playbar_controls_available(app: &App) -> bool { + app.current_playback_context.as_ref().is_some_and(|ctx| { + ctx.item.is_some() || (app.is_streaming_active && app.native_device_id.is_some()) + }) +} + pub(crate) fn playbar_control_at( app: &App, playbar_area: Rect, @@ -973,6 +974,63 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { app.cover_art.render(f, cover_art); } + drew_playbar = true; + } else if app.is_streaming_active && app.native_device_id.is_some() { + let shuffle_text = if current_playback_context.shuffle_state { + "On" + } else { + "Off" + }; + let repeat_text = match current_playback_context.repeat_state { + RepeatState::Off => "Off", + RepeatState::Track => "Track", + RepeatState::Context => "All", + }; + let title = format!( + "Ready ({} | Shuffle: {:-3} | Repeat: {:-5} | Volume: {:-2}%)", + current_playback_context.device.name, + shuffle_text, + repeat_text, + app.desired_volume() + ); + let current_route = app.get_current_route(); + let highlight_state = ( + matches!( + current_route.active_block, + ActiveBlock::PlayBar | ActiveBlock::MiniPlayer + ), + matches!( + current_route.hovered_block, + ActiveBlock::PlayBar | ActiveBlock::MiniPlayer + ), + ); + let mut title_spans = vec![Span::styled( + title, + get_color(highlight_state, app.user_config.theme), + )]; + if let Some(message) = app.status_message.as_ref() { + let msg_style = if app.status_message_is_error { + Style::default().fg(app.user_config.theme.error_text) + } else { + get_color(highlight_state, app.user_config.theme) + }; + title_spans.push(Span::styled(format!(" | {}", message), msg_style)); + } + + let title_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(app.user_config.theme.playbar_background)) + .title(Line::from(title_spans)) + .border_style(get_color(highlight_state, app.user_config.theme)); + + f.render_widget(title_block, layout_chunk); + f.render_widget( + Paragraph::new("No active playback") + .style(Style::default().fg(app.user_config.theme.playbar_text)), + artist_area, + ); + draw_playbar_controls(f, app, playbar_areas.controls_area); drew_playbar = true; } } @@ -1064,6 +1122,42 @@ pub fn draw_device_list(f: &mut Frame<'_>, app: &App) { #[cfg(test)] mod tests { use super::*; + use chrono::Utc; + use rspotify::model::{ + context::{Actions, CurrentPlaybackContext}, + device::Device, + enums::{CurrentlyPlayingType, RepeatState}, + DeviceType, + }; + + #[allow(deprecated)] + fn idle_native_app() -> App { + let mut app = App::default(); + app.is_streaming_active = true; + app.native_device_id = Some("native-device".to_string()); + app.current_playback_context = Some(CurrentPlaybackContext { + device: Device { + id: Some("native-device".to_string()), + is_active: true, + is_private_session: false, + is_restricted: false, + name: "spotatui".to_string(), + _type: DeviceType::Computer, + volume_percent: Some(50), + }, + repeat_state: RepeatState::Off, + shuffle_state: false, + context: None, + timestamp: Utc::now(), + progress: None, + is_playing: false, + item: None, + currently_playing_type: CurrentlyPlayingType::Unknown, + actions: Actions::default(), + }); + app.set_current_route_state(Some(ActiveBlock::PlayBar), Some(ActiveBlock::PlayBar)); + app + } #[test] fn control_hitboxes_handle_zero_sized_area() { @@ -1090,6 +1184,14 @@ mod tests { ); } + #[test] + fn playbar_control_hitboxes_include_controls_for_idle_native_device() { + let app = idle_native_app(); + let hitboxes = playbar_control_hitboxes(&app, Rect::new(0, 0, 200, 6)); + + assert_eq!(hitboxes.len(), PLAYBAR_CONTROLS.len()); + } + #[cfg(feature = "cover-art")] #[test] fn center_rect_within_centers_smaller_rect() {