Background-audio handoff for video + repository/player refactor
Hand video playback off to a native audio-only stream when the app is backgrounded or locked, with no on-device video decode (UR-040). Adds player_enter/exit_background_audio commands, an audio-only stream URL for video items across the repository layer, and the frontend handoff state machine wired into VideoPlayer. Includes accompanying repository/offline/player refactoring and regenerates the traceability matrix.
This commit is contained in:
+397
-90
@@ -42,8 +42,8 @@ pub use mpv_backend::MpvBackend;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::{
|
||||
MediaCommandHandler, RemoteVolumeHandler, enable_remote_volume, disable_remote_volume,
|
||||
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
||||
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
|
||||
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
||||
};
|
||||
|
||||
/// Metadata for the lockscreen / media notification.
|
||||
@@ -53,6 +53,9 @@ pub use android::{
|
||||
/// poller fills this in from the remote Jellyfin session and pushes it to the
|
||||
/// notification so the lockscreen stays in sync while casting.
|
||||
#[derive(Debug, Clone)]
|
||||
// Fields are read only by the Android MediaSession bridge; on other platforms
|
||||
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub struct LockscreenMetadata {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
@@ -84,9 +87,11 @@ use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::playback_reporting::{
|
||||
EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
|
||||
};
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation, PlaybackContext};
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
/// Central player controller that coordinates playback
|
||||
pub struct PlayerController {
|
||||
@@ -157,7 +162,10 @@ impl PlayerController {
|
||||
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
||||
let mut jellyfin = self.jellyfin_client.lock_safe();
|
||||
*jellyfin = client;
|
||||
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
||||
log::info!(
|
||||
"[PlayerController] Jellyfin client configured: {}",
|
||||
jellyfin.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a reference to the Jellyfin client (for remote session control)
|
||||
@@ -180,7 +188,10 @@ impl PlayerController {
|
||||
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
|
||||
let mut reporter_guard = self.playback_reporter.lock().await;
|
||||
*reporter_guard = reporter;
|
||||
log::info!("[PlayerController] Playback reporter configured: {}", reporter_guard.is_some());
|
||||
log::info!(
|
||||
"[PlayerController] Playback reporter configured: {}",
|
||||
reporter_guard.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a reference to the playback reporter (for backend position updates)
|
||||
@@ -219,7 +230,10 @@ impl PlayerController {
|
||||
|
||||
let mut count = self.autoplay_episode_count.lock_safe();
|
||||
*count += 1;
|
||||
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
|
||||
debug!(
|
||||
"[PlayerController] Autoplay episode count: {}/{}",
|
||||
*count, max
|
||||
);
|
||||
|
||||
*count >= max
|
||||
}
|
||||
@@ -228,7 +242,10 @@ impl PlayerController {
|
||||
fn reset_autoplay_count(&self) {
|
||||
let mut count = self.autoplay_episode_count.lock_safe();
|
||||
if *count > 0 {
|
||||
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
|
||||
debug!(
|
||||
"[PlayerController] Resetting autoplay episode counter (was {})",
|
||||
*count
|
||||
);
|
||||
}
|
||||
*count = 0;
|
||||
}
|
||||
@@ -259,7 +276,10 @@ impl PlayerController {
|
||||
/// item, but MPV must not start a redundant decode for it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
|
||||
debug!(
|
||||
"[PlayerController] set_current_item (no backend load): {}",
|
||||
item.title
|
||||
);
|
||||
|
||||
self.reset_autoplay_count();
|
||||
|
||||
@@ -446,7 +466,9 @@ impl PlayerController {
|
||||
// Get current playback info before stopping
|
||||
let jellyfin_id = {
|
||||
let queue = self.queue.lock_safe();
|
||||
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
||||
queue
|
||||
.current()
|
||||
.and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
||||
};
|
||||
|
||||
let position_ticks = {
|
||||
@@ -522,7 +544,10 @@ impl PlayerController {
|
||||
queue.next().cloned()
|
||||
};
|
||||
|
||||
debug!("[PlayerController] next: {:?}", next_item.as_ref().map(|i| &i.title));
|
||||
debug!(
|
||||
"[PlayerController] next: {:?}",
|
||||
next_item.as_ref().map(|i| &i.title)
|
||||
);
|
||||
|
||||
if let Some(item) = next_item {
|
||||
self.load_and_play(&item)
|
||||
@@ -554,7 +579,10 @@ impl PlayerController {
|
||||
queue.previous().cloned()
|
||||
};
|
||||
|
||||
debug!("[PlayerController] previous: {:?}", prev_item.as_ref().map(|i| &i.title));
|
||||
debug!(
|
||||
"[PlayerController] previous: {:?}",
|
||||
prev_item.as_ref().map(|i| &i.title)
|
||||
);
|
||||
|
||||
if let Some(item) = prev_item {
|
||||
self.load_and_play(&item)
|
||||
@@ -707,7 +735,9 @@ impl PlayerController {
|
||||
timer.update_remaining_seconds();
|
||||
|
||||
// Time-based timer expired: stop playback
|
||||
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
|
||||
if matches!(timer.mode, SleepTimerMode::Time { .. })
|
||||
&& timer.remaining_seconds == 0
|
||||
{
|
||||
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||
timer.cancel();
|
||||
|
||||
@@ -843,7 +873,10 @@ impl PlayerController {
|
||||
// Check why playback ended
|
||||
let end_reason = self.take_end_reason();
|
||||
|
||||
debug!("[PlayerController] on_playback_ended: end_reason={:?}", end_reason);
|
||||
debug!(
|
||||
"[PlayerController] on_playback_ended: end_reason={:?}",
|
||||
end_reason
|
||||
);
|
||||
|
||||
// Only proceed with autoplay logic if track finished naturally
|
||||
match end_reason {
|
||||
@@ -907,8 +940,8 @@ impl PlayerController {
|
||||
}
|
||||
SleepTimerMode::Episodes { .. } => {
|
||||
// Only count TV episodes (not audio tracks or movies)
|
||||
let is_episode = current.media_type == MediaType::Video
|
||||
&& self.is_episode_item(¤t).await;
|
||||
let is_episode =
|
||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).await;
|
||||
|
||||
if is_episode {
|
||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||
@@ -936,7 +969,10 @@ impl PlayerController {
|
||||
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
|
||||
warn!(
|
||||
"[PlayerController] Next-episode lookup failed for {}: {}",
|
||||
jellyfin_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -950,11 +986,14 @@ impl PlayerController {
|
||||
// Check if auto-play episode limit is reached
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
next_episode: next_ep.1,
|
||||
countdown_seconds: settings.countdown_seconds,
|
||||
auto_advance: settings.enabled && !limit_reached,
|
||||
@@ -993,10 +1032,16 @@ impl PlayerController {
|
||||
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
|
||||
let stale_reason = self.take_end_reason();
|
||||
if stale_reason.is_some() {
|
||||
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
|
||||
debug!(
|
||||
"[PlayerController] Cleared stale end_reason for video: {:?}",
|
||||
stale_reason
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
|
||||
log::info!(
|
||||
"[PlayerController] on_video_playback_ended: item_id={}",
|
||||
item_id
|
||||
);
|
||||
|
||||
// Check sleep timer state
|
||||
let timer_mode = {
|
||||
@@ -1035,7 +1080,10 @@ impl PlayerController {
|
||||
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
|
||||
warn!(
|
||||
"[PlayerController] Next-episode lookup failed for {}: {}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -1044,7 +1092,10 @@ impl PlayerController {
|
||||
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
@@ -1077,11 +1128,18 @@ impl PlayerController {
|
||||
&self,
|
||||
item_id: &str,
|
||||
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
||||
) -> Result<
|
||||
Option<(
|
||||
crate::repository::types::MediaItem,
|
||||
crate::repository::types::MediaItem,
|
||||
)>,
|
||||
String,
|
||||
> {
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
|
||||
// Get the current item details from repository
|
||||
let current_repo_item = repo.get_item(item_id)
|
||||
let current_repo_item = repo
|
||||
.get_item(item_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
||||
|
||||
@@ -1089,7 +1147,9 @@ impl PlayerController {
|
||||
let season_id = match ¤t_repo_item.season_id {
|
||||
Some(sid) => sid.clone(),
|
||||
None => {
|
||||
log::info!("[PlayerController] Current item has no season_id, cannot find next episode");
|
||||
log::info!(
|
||||
"[PlayerController] Current item has no season_id, cannot find next episode"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
@@ -1103,7 +1163,8 @@ impl PlayerController {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = repo.get_items(&season_id, Some(options))
|
||||
let result = repo
|
||||
.get_items(&season_id, Some(options))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
|
||||
|
||||
@@ -1111,26 +1172,45 @@ impl PlayerController {
|
||||
// (offline repo ignores sort_by and sorts by sort_name instead)
|
||||
let mut episodes = result.items;
|
||||
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||
log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
|
||||
log::info!(
|
||||
"[PlayerController] Season has {} episodes, looking for next after {}",
|
||||
episodes.len(),
|
||||
current_repo_item.id
|
||||
);
|
||||
|
||||
// Find the current episode by ID and return the next one
|
||||
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
|
||||
if current_idx + 1 < episodes.len() {
|
||||
let next = &episodes[current_idx + 1];
|
||||
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
|
||||
log::info!(
|
||||
"[PlayerController] Found next episode: {} (index {})",
|
||||
next.name,
|
||||
current_idx + 1
|
||||
);
|
||||
return Ok(Some((current_repo_item, next.clone())));
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode is the last in the season");
|
||||
}
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
|
||||
log::info!(
|
||||
"[PlayerController] Current episode not found in season episodes (ids: {:?})",
|
||||
episodes
|
||||
.iter()
|
||||
.map(|e| e.id.as_str())
|
||||
.take(20)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Start autoplay countdown thread
|
||||
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
|
||||
pub fn start_autoplay_countdown(
|
||||
&self,
|
||||
_next_item: crate::repository::types::MediaItem,
|
||||
countdown_seconds: u32,
|
||||
) {
|
||||
// Create cancellation flag
|
||||
let cancel_flag = Arc::new(Mutex::new(false));
|
||||
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
|
||||
@@ -1169,7 +1249,11 @@ impl Default for PlayerController {
|
||||
fn default() -> Self {
|
||||
let playback_reporter = Arc::new(TokioMutex::new(None));
|
||||
let position_throttler = Arc::new(EventThrottler::new());
|
||||
Self::new(Box::new(NullBackend::new()), playback_reporter, position_throttler)
|
||||
Self::new(
|
||||
Box::new(NullBackend::new()),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,8 +1416,16 @@ mod tests {
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
|
||||
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(0),
|
||||
"Should start at index 0"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_0",
|
||||
"Current item should be item_0"
|
||||
);
|
||||
}
|
||||
|
||||
// Skip to next track
|
||||
@@ -1343,15 +1435,35 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
|
||||
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items after skip"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(1),
|
||||
"Index should advance to 1"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_1",
|
||||
"Current item should be item_1"
|
||||
);
|
||||
|
||||
// Verify all original items are still present
|
||||
let current_items = queue_lock.items();
|
||||
for (i, original) in items_clone.iter().enumerate() {
|
||||
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
|
||||
assert_eq!(current_items[i].title, original.title, "Item {} title should be unchanged", i);
|
||||
assert_eq!(
|
||||
current_items[i].id, original.id,
|
||||
"Item {} should still be in queue",
|
||||
i
|
||||
);
|
||||
assert_eq!(
|
||||
current_items[i].title, original.title,
|
||||
"Item {} title should be unchanged",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1362,9 +1474,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
|
||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items after second skip"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(2),
|
||||
"Index should advance to 2"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_2",
|
||||
"Current item should be item_2"
|
||||
);
|
||||
}
|
||||
|
||||
// Skip multiple times to reach the end
|
||||
@@ -1375,9 +1499,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
|
||||
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items at end"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(4),
|
||||
"Index should be at last item (4)"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_4",
|
||||
"Current item should be item_4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,7 +1533,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(2),
|
||||
"Should be at last item"
|
||||
);
|
||||
}
|
||||
|
||||
// Try to skip past the end (without repeat mode)
|
||||
@@ -1408,7 +1548,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
3,
|
||||
"Queue should still have 3 items after skip at end"
|
||||
);
|
||||
// When we skip past the end, the queue index should stay at the last item
|
||||
// or become None (depending on implementation)
|
||||
// The key is the queue items themselves should be preserved
|
||||
@@ -1437,9 +1581,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
|
||||
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
3,
|
||||
"Queue should still have 3 items"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(0),
|
||||
"Should wrap to index 0"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_0",
|
||||
"Should be back at item_0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,7 +1612,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(3),
|
||||
"Should start at index 3"
|
||||
);
|
||||
}
|
||||
|
||||
// Go to previous track
|
||||
@@ -1466,14 +1626,30 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
|
||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items after previous"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(2),
|
||||
"Index should move to 2"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_2",
|
||||
"Current item should be item_2"
|
||||
);
|
||||
|
||||
// Verify all original items are still present
|
||||
let current_items = queue_lock.items();
|
||||
for (i, original) in items_clone.iter().enumerate() {
|
||||
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
|
||||
assert_eq!(
|
||||
current_items[i].id, original.id,
|
||||
"Item {} should still be in queue",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1491,15 +1667,27 @@ mod tests {
|
||||
|
||||
// Seek to 30 seconds
|
||||
controller.seek(30.0).unwrap();
|
||||
assert_eq!(controller.position(), 30.0, "Position should be 30 after seeking");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
30.0,
|
||||
"Position should be 30 after seeking"
|
||||
);
|
||||
|
||||
// Seek to 60 seconds
|
||||
controller.seek(60.0).unwrap();
|
||||
assert_eq!(controller.position(), 60.0, "Position should be 60 after seeking");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
60.0,
|
||||
"Position should be 60 after seeking"
|
||||
);
|
||||
|
||||
// Seek backward to 15 seconds
|
||||
controller.seek(15.0).unwrap();
|
||||
assert_eq!(controller.position(), 15.0, "Position should be 15 after seeking backward");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
15.0,
|
||||
"Position should be 15 after seeking backward"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1518,10 +1706,17 @@ mod tests {
|
||||
|
||||
// Seek while paused
|
||||
controller.seek(45.0).unwrap();
|
||||
assert_eq!(controller.position(), 45.0, "Position should update while paused");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
45.0,
|
||||
"Position should update while paused"
|
||||
);
|
||||
|
||||
// Verify still paused after seeking
|
||||
assert!(controller.state().is_paused(), "Should still be paused after seeking");
|
||||
assert!(
|
||||
controller.state().is_paused(),
|
||||
"Should still be paused after seeking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1540,10 +1735,17 @@ mod tests {
|
||||
|
||||
// Seek while playing
|
||||
controller.seek(20.0).unwrap();
|
||||
assert_eq!(controller.position(), 20.0, "Position should update while playing");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
20.0,
|
||||
"Position should update while playing"
|
||||
);
|
||||
|
||||
// Verify still playing after seeking
|
||||
assert!(controller.state().is_playing(), "Should still be playing after seeking");
|
||||
assert!(
|
||||
controller.state().is_playing(),
|
||||
"Should still be playing after seeking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1558,7 +1760,12 @@ mod tests {
|
||||
|
||||
for pos in positions {
|
||||
controller.seek(pos).unwrap();
|
||||
assert_eq!(controller.position(), pos, "Position should match after seeking to {}", pos);
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
pos,
|
||||
"Position should match after seeking to {}",
|
||||
pos
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1575,9 +1782,17 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(1),
|
||||
"Should start at index 1"
|
||||
);
|
||||
}
|
||||
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
42.5,
|
||||
"Should resume at the requested position"
|
||||
);
|
||||
}
|
||||
|
||||
/// A None / near-zero start position starts the track from the beginning.
|
||||
@@ -1613,7 +1828,11 @@ mod tests {
|
||||
|
||||
// Seek back to zero
|
||||
controller.seek(0.0).unwrap();
|
||||
assert_eq!(controller.position(), 0.0, "Should be able to seek to position 0");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
0.0,
|
||||
"Should be able to seek to position 0"
|
||||
);
|
||||
}
|
||||
|
||||
// Autoplay decision tests
|
||||
@@ -1990,7 +2209,10 @@ mod tests {
|
||||
total_record_count: self.episodes.len(),
|
||||
})
|
||||
}
|
||||
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
async fn get_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
self.episodes
|
||||
.iter()
|
||||
.find(|e| e.id == item_id)
|
||||
@@ -1999,55 +2221,109 @@ mod tests {
|
||||
message: format!("{} not found", item_id),
|
||||
})
|
||||
}
|
||||
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_recently_played_audio(
|
||||
&self,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_resume_movies(
|
||||
&self,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
|
||||
async fn get_genres(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn search(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<repo_types::SearchOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
|
||||
async fn get_playback_info(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_live_tv_channels(
|
||||
&self,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
|
||||
async fn open_live_stream(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_start(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_progress(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_stopped(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
|
||||
fn get_image_url(
|
||||
&self,
|
||||
_: &str,
|
||||
_: repo_types::ImageType,
|
||||
_: Option<repo_types::ImageOptions>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
||||
@@ -2062,16 +2338,31 @@ mod tests {
|
||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
async fn get_person(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<repo_types::GetItemsOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn get_similar_items(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
@@ -2080,16 +2371,32 @@ mod tests {
|
||||
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
|
||||
async fn move_playlist_item(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: u32,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user