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:
@@ -36,7 +36,7 @@ impl Default for CacheConfig {
|
||||
album_affinity_enabled: true,
|
||||
album_affinity_threshold: 3,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,10 @@ impl SmartCache {
|
||||
"DELETE FROM downloads WHERE id = ?",
|
||||
vec![QueryParam::Int64(id)],
|
||||
);
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
freed += size as u64;
|
||||
}
|
||||
|
||||
@@ -8,16 +8,10 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum DownloadEvent {
|
||||
/// Download has been queued
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Queued {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Queued { download_id: i64, item_id: String },
|
||||
/// Download has started
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Started { download_id: i64, item_id: String },
|
||||
/// Download progress update
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
@@ -43,16 +37,10 @@ pub enum DownloadEvent {
|
||||
},
|
||||
/// Download paused
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Paused {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Paused { download_id: i64, item_id: String },
|
||||
/// Download cancelled
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Cancelled {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
Cancelled { download_id: i64, item_id: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -98,9 +86,21 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"completed\""));
|
||||
// Verify camelCase field names
|
||||
assert!(json.contains("\"downloadId\":42"), "Expected downloadId (camelCase), got: {}", json);
|
||||
assert!(json.contains("\"itemId\":\"song456\""), "Expected itemId (camelCase), got: {}", json);
|
||||
assert!(json.contains("\"filePath\":"), "Expected filePath (camelCase), got: {}", json);
|
||||
assert!(
|
||||
json.contains("\"downloadId\":42"),
|
||||
"Expected downloadId (camelCase), got: {}",
|
||||
json
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"itemId\":\"song456\""),
|
||||
"Expected itemId (camelCase), got: {}",
|
||||
json
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"filePath\":"),
|
||||
"Expected filePath (camelCase), got: {}",
|
||||
json
|
||||
);
|
||||
|
||||
// Verify roundtrip
|
||||
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -11,8 +11,8 @@ pub mod events;
|
||||
pub mod worker;
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use worker::DownloadWorker;
|
||||
|
||||
@@ -60,7 +60,11 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
/// Attempt a single download
|
||||
async fn try_download<F>(&self, task: &DownloadTask, on_progress: &F) -> Result<DownloadResult, DownloadError>
|
||||
async fn try_download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
on_progress: &F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
F: Fn(u64, Option<u64>) + Send + Sync,
|
||||
{
|
||||
@@ -74,10 +78,7 @@ impl DownloadWorker {
|
||||
// Check for partial download
|
||||
let temp_path = task.target_path.with_extension("part");
|
||||
let existing_bytes = if temp_path.exists() {
|
||||
fs::metadata(&temp_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0)
|
||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -105,14 +106,17 @@ impl DownloadWorker {
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|len| if existing_bytes > 0 { len + existing_bytes } else { len });
|
||||
.map(|len| {
|
||||
if existing_bytes > 0 {
|
||||
len + existing_bytes
|
||||
} else {
|
||||
len
|
||||
}
|
||||
});
|
||||
|
||||
// Open file for appending
|
||||
let mut file = if existing_bytes > 0 {
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&temp_path)
|
||||
.await
|
||||
fs::OpenOptions::new().append(true).open(&temp_path).await
|
||||
} else {
|
||||
fs::File::create(&temp_path).await
|
||||
}
|
||||
@@ -206,9 +210,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
assert_eq!(DownloadWorker::exponential_backoff(1), Duration::from_secs(5));
|
||||
assert_eq!(DownloadWorker::exponential_backoff(2), Duration::from_secs(15));
|
||||
assert_eq!(DownloadWorker::exponential_backoff(3), Duration::from_secs(45));
|
||||
assert_eq!(
|
||||
DownloadWorker::exponential_backoff(1),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
assert_eq!(
|
||||
DownloadWorker::exponential_backoff(2),
|
||||
Duration::from_secs(15)
|
||||
);
|
||||
assert_eq!(
|
||||
DownloadWorker::exponential_backoff(3),
|
||||
Duration::from_secs(45)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user