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:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
+5 -2
View File
@@ -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;
}
+19 -19
View File
@@ -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();
+1 -1
View File
@@ -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;
+26 -13
View File
@@ -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]