Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 8 additions & 3 deletions src/infra/network/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,12 @@ impl LibraryNetwork for Network {
}

#[cfg(feature = "streaming")]
let folder_nodes = fetch_rootlist_folders(&self.streaming_player).await;
let streaming_player = {
let app = self.app.lock().await;
app.streaming_player.clone()
};
#[cfg(feature = "streaming")]
let folder_nodes = fetch_rootlist_folders(streaming_player).await;
#[cfg(not(feature = "streaming"))]
let folder_nodes: Option<Vec<PlaylistFolderNode>> = None;

Expand Down Expand Up @@ -860,9 +865,9 @@ mod tests {

#[cfg(feature = "streaming")]
async fn fetch_rootlist_folders(
streaming_player: &Option<Arc<StreamingPlayer>>,
streaming_player: Option<Arc<StreamingPlayer>>,
) -> Option<Vec<PlaylistFolderNode>> {
let player = streaming_player.as_ref()?;
let player = streaming_player?;
let session = player.session();

let bytes = match session.spclient().get_rootlist(0, Some(100_000)).await {
Expand Down
7 changes: 0 additions & 7 deletions src/infra/network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;

#[cfg(feature = "streaming")]
use crate::infra::player::StreamingPlayer;

// Re-export traits
use self::library::LibraryNetwork;
use self::metadata::MetadataNetwork;
Expand Down Expand Up @@ -151,8 +148,6 @@ pub struct Network {
pub small_search_limit: u32,
pub client_config: ClientConfig,
pub app: Arc<Mutex<App>>,
#[cfg(feature = "streaming")]
pub streaming_player: Option<Arc<StreamingPlayer>>,
pub party_connection: Option<sync::PartyConnection>,
pub party_incoming_rx: Option<tokio::sync::mpsc::UnboundedReceiver<sync::SyncMessage>>,
}
Expand All @@ -163,15 +158,13 @@ impl Network {
spotify: AuthCodePkceSpotify,
client_config: ClientConfig,
app: &Arc<Mutex<App>>,
streaming_player: Option<Arc<StreamingPlayer>>,
) -> Self {
Network {
spotify,
large_search_limit: 50,
small_search_limit: 4,
client_config,
app: Arc::clone(app),
streaming_player,
party_connection: None,
party_incoming_rx: None,
}
Expand Down
174 changes: 114 additions & 60 deletions src/infra/network/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ use chrono::TimeDelta;
use rspotify::model::{
enums::RepeatState,
idtypes::{PlayContextId, PlayableId},
PlayableItem,
Offset, PlayableItem,
};
use rspotify::prelude::*;
use std::time::{Duration, Instant};

#[cfg(feature = "streaming")]
use librespot_connect::{LoadRequest, LoadRequestOptions, PlayingTrack};
#[cfg(feature = "streaming")]
use std::sync::Arc;

const MAX_API_PLAYBACK_URIS: usize = 100;

Expand Down Expand Up @@ -69,26 +71,43 @@ fn trim_api_playback_uris(
(trimmed_uris, Some(selected_index - start))
}

fn api_playback_offset(
context_uris: Option<&[PlayableId<'static>]>,
offset: Option<usize>,
) -> Option<Offset> {
if let Some(first_uri) = context_uris.and_then(|uris| uris.first()) {
return Some(Offset::Uri(first_uri.uri()));
Comment on lines +78 to +79
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

api_playback_offset ignores the provided offset whenever context_uris is non-empty (it always picks uris.first()). If a caller ever provides a context URI list with more than one element and an offset > 0, this will still start playback from the first item instead of the requested one. Consider selecting context_uris.get(offset.unwrap_or(0)) (with bounds fallback) and only falling back to Offset::Position(...) when no URI can be derived.

Suggested change
if let Some(first_uri) = context_uris.and_then(|uris| uris.first()) {
return Some(Offset::Uri(first_uri.uri()));
if let Some(uris) = context_uris {
let index = offset.unwrap_or(0);
if let Some(selected_uri) = uris.get(index).or_else(|| uris.first()) {
return Some(Offset::Uri(selected_uri.uri()));
}

Copilot uses AI. Check for mistakes.
}

offset.map(|index| Offset::Position(ChronoDuration::milliseconds(index as i64)))
}

/// Get the currently active streaming player, if any.
/// Note: This logic is duplicated in `main.rs` as `active_streaming_player()`.
/// Both are identical; the difference is input type (Network vs. App Arc).
/// A future refactor could consolidate to a shared location like `src/core/app.rs`.
#[cfg(feature = "streaming")]
async fn current_streaming_player(
network: &Network,
) -> Option<Arc<crate::infra::player::StreamingPlayer>> {
let app = network.app.lock().await;
app.streaming_player.clone()
}

#[cfg(feature = "streaming")]
async fn is_native_streaming_active_for_playback(network: &Network) -> bool {
let player_connected = network
.streaming_player
.as_ref()
.is_some_and(|p| p.is_connected());
let app = network.app.lock().await;
let streaming_player = app.streaming_player.clone();
let player_connected = streaming_player.as_ref().is_some_and(|p| p.is_connected());

if !player_connected {
return false;
}

// Get native device name once (no lock needed)
let native_device_name = network
.streaming_player
let native_device_name = streaming_player
.as_ref()
.map(|p| p.device_name().to_lowercase());

// Single lock acquisition - check all conditions in one go
let app = network.app.lock().await;

// If no context yet (e.g., at startup), use the app state flag which is
// set when the native streaming device is activated/selected.
let Some(ref ctx) = app.current_playback_context else {
Expand Down Expand Up @@ -116,25 +135,21 @@ async fn is_native_streaming_active_for_playback(network: &Network) -> bool {
false
}

#[cfg(feature = "streaming")]
fn is_native_streaming_active(network: &Network) -> bool {
network
.streaming_player
.as_ref()
.is_some_and(|p| p.is_connected())
}

impl PlaybackNetwork for Network {
async fn get_current_playback(&mut self) {
// When using native streaming, the Spotify API returns stale server-side state
// that doesn't reflect recent local changes (volume, shuffle, repeat, play/pause).
// We need to preserve these local states and restore them after getting the API response.
#[cfg(feature = "streaming")]
let streaming_player = current_streaming_player(self).await;
#[cfg(feature = "streaming")]
// Check if native streaming is active by examining the pre-fetched player
// (avoids redundant lock call from is_native_streaming_active)
let local_state: Option<(Option<u8>, bool, rspotify::model::RepeatState, Option<bool>)> =
if is_native_streaming_active(self) {
if streaming_player.as_ref().is_some_and(|p| p.is_connected()) {
let app = self.app.lock().await;
if let Some(ref ctx) = app.current_playback_context {
let volume = self.streaming_player.as_ref().map(|p| p.get_volume());
let volume = streaming_player.as_ref().map(|p| p.get_volume());
Some((
volume,
ctx.shuffle_state,
Expand Down Expand Up @@ -164,7 +179,7 @@ impl PlaybackNetwork for Network {

// Detect whether the native spotatui streaming device is the active Spotify device.
#[cfg(feature = "streaming")]
let is_native_device = self.streaming_player.as_ref().is_some_and(|p| {
let is_native_device = streaming_player.as_ref().is_some_and(|p| {
if let (Some(current_id), Some(native_id)) =
(c.device.id.as_ref(), app.native_device_id.as_ref())
{
Expand Down Expand Up @@ -236,7 +251,7 @@ impl PlaybackNetwork for Network {
if local_state.is_none() && is_native_device {
c.shuffle_state = app.user_config.behavior.shuffle_enabled;
// Proactively set native shuffle on first load to keep backend in sync
if let Some(ref player) = self.streaming_player {
if let Some(ref player) = streaming_player {
let _ = player.set_shuffle(app.user_config.behavior.shuffle_enabled);
}
}
Expand Down Expand Up @@ -386,7 +401,7 @@ impl PlaybackNetwork for Network {
// Check if we should use native streaming for playback
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
let activation_time = Instant::now();
let should_transfer = {
let app = self.app.lock().await;
Expand Down Expand Up @@ -481,31 +496,32 @@ impl PlaybackNetwork for Network {
}
}

let offset_struct =
offset.map(|o| rspotify::model::Offset::Position(ChronoDuration::milliseconds(o as i64)));

let result = if let Some(context) = context_id {
self
.spotify
.start_context_playback(
context,
None, // device_id
offset_struct,
None, // position
)
.await
} else if let Some(track_uris) = uris {
self
.spotify
.start_uris_playback(
track_uris,
None, // device_id
offset_struct,
None, // position
)
.await
} else {
self.spotify.resume_playback(None, None).await
let result = match (context_id, uris) {
(Some(context), track_uris) => {
let offset_struct = api_playback_offset(track_uris.as_deref(), offset);
self
.spotify
.start_context_playback(
context,
None, // device_id
offset_struct,
None, // position
)
.await
}
(None, Some(track_uris)) => {
let offset_struct = api_playback_offset(None, offset);
self
.spotify
.start_uris_playback(
track_uris,
None, // device_id
offset_struct,
None, // position
)
.await
}
(None, None) => self.spotify.resume_playback(None, None).await,
};

match result {
Expand Down Expand Up @@ -533,7 +549,7 @@ impl PlaybackNetwork for Network {
// Check if using native streaming
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
player.pause();
// Update UI state immediately
let mut app = self.app.lock().await;
Expand Down Expand Up @@ -561,7 +577,7 @@ impl PlaybackNetwork for Network {
async fn next_track(&mut self) {
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
player.next();
return;
}
Expand All @@ -576,7 +592,7 @@ impl PlaybackNetwork for Network {
async fn previous_track(&mut self) {
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
player.prev();
return;
}
Expand All @@ -591,7 +607,7 @@ impl PlaybackNetwork for Network {
async fn force_previous_track(&mut self) {
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
player.prev();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
player.prev();
Expand Down Expand Up @@ -619,7 +635,7 @@ impl PlaybackNetwork for Network {
async fn seek(&mut self, position_ms: u32) {
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
player.seek(position_ms);
return;
}
Expand All @@ -638,7 +654,7 @@ impl PlaybackNetwork for Network {
async fn shuffle(&mut self, shuffle_state: bool) {
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
let _ = player.set_shuffle(shuffle_state);
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
Expand All @@ -665,7 +681,7 @@ impl PlaybackNetwork for Network {
async fn repeat(&mut self, repeat_state: RepeatState) {
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
let _ = player.set_repeat(repeat_state);
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
Expand All @@ -692,7 +708,7 @@ impl PlaybackNetwork for Network {
async fn change_volume(&mut self, volume: u8) {
#[cfg(feature = "streaming")]
if is_native_streaming_active_for_playback(self).await {
if let Some(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
player.set_volume(volume);
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
Expand All @@ -719,7 +735,8 @@ impl PlaybackNetwork for Network {
async fn transfert_playback_to_device(&mut self, device_id: String, persist_device_id: bool) {
#[cfg(feature = "streaming")]
{
let is_native_transfer = if let Some(ref player) = self.streaming_player {
let streaming_player = current_streaming_player(self).await;
let is_native_transfer = if let Some(ref player) = streaming_player {
let native_name = player.device_name().to_lowercase();
let app = self.app.lock().await;
let matches_cached_device = app.devices.as_ref().is_some_and(|payload| {
Expand All @@ -734,7 +751,7 @@ impl PlaybackNetwork for Network {
};

if is_native_transfer {
if let Some(ref player) = self.streaming_player {
if let Some(ref player) = streaming_player {
let _ = player.transfer(None);
player.activate();
let mut app = self.app.lock().await;
Expand Down Expand Up @@ -772,7 +789,7 @@ impl PlaybackNetwork for Network {
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(ref player) = self.streaming_player {
if let Some(player) = current_streaming_player(self).await {
let activation_time = Instant::now();
let should_transfer = {
let app = self.app.lock().await;
Expand Down Expand Up @@ -949,4 +966,41 @@ mod tests {
assert_eq!(offset, Some(99));
assert_eq!(trimmed[offset.unwrap()].uri(), uris[149].uri());
}

#[test]
fn api_playback_offset_uses_track_uri_for_context_playback() {
let uris = vec![
playable_track("0000000000000000000001"),
playable_track("0000000000000000000002"),
];

let offset = api_playback_offset(Some(&uris), Some(1));

assert_eq!(
offset,
Some(Offset::Uri(
"spotify:track:0000000000000000000001".to_string()
))
);
Comment on lines +971 to +984
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The test api_playback_offset_uses_track_uri_for_context_playback passes offset = Some(1) but asserts that the first URI is used. If api_playback_offset is updated to honor offsets for multi-URI context playback (recommended), this test will start failing for the right reason. Adjust/add test cases to explicitly cover offset=0 vs offset=1 selection (and any bounds/fallback behavior) instead of encoding offset-ignoring behavior.

Copilot uses AI. Check for mistakes.
}

#[test]
fn api_playback_offset_uses_position_for_uri_list_playback() {
let offset = api_playback_offset(None, Some(1));

assert_eq!(
offset,
Some(Offset::Position(ChronoDuration::milliseconds(1)))
);
}

#[test]
fn api_playback_offset_falls_back_to_position_when_context_has_no_uri() {
let offset = api_playback_offset(None, Some(3));

assert_eq!(
offset,
Some(Offset::Position(ChronoDuration::milliseconds(3)))
);
}
}
Loading
Loading