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
+47 -22
View File
@@ -75,7 +75,10 @@ impl ThumbnailCache {
],
);
if let Ok(Some(path_str)) = db.query_optional(exact, |row| row.get::<_, String>(0)).await {
if let Ok(Some(path_str)) = db
.query_optional(exact, |row| row.get::<_, String>(0))
.await
{
let path = PathBuf::from(&path_str);
if path.exists() {
self.touch(&db, item_id, image_type, Some(tag)).await;
@@ -83,14 +86,16 @@ impl ThumbnailCache {
}
// File gone — drop the stale row and fall through to the tag-agnostic
// lookup below (another cached image for this item may still exist).
let _ = db.execute(Query::with_params(
"DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
],
)).await;
let _ = db
.execute(Query::with_params(
"DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
],
))
.await;
}
// Fallback: any cached image for this item + type, newest first. The
@@ -204,7 +209,11 @@ impl ThumbnailCache {
}
/// Ensure there's enough space by evicting LRU items if needed
async fn ensure_space(&self, db: Arc<RusqliteService>, needed_bytes: u64) -> Result<(), String> {
async fn ensure_space(
&self,
db: Arc<RusqliteService>,
needed_bytes: u64,
) -> Result<(), String> {
let max_size = {
let config = self.config.lock().map_err(|e| e.to_string())?;
config.max_size_bytes
@@ -236,9 +245,7 @@ impl ThumbnailCache {
);
let items: Vec<(i64, String, i64)> = db
.query_many(query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
@@ -276,9 +283,7 @@ impl ThumbnailCache {
pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
let query = Query::new("SELECT COUNT(*) FROM thumbnails");
db.query_one(query, |row| row.get(0))
.await
.unwrap_or(0)
db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
}
/// Get the current cache limit in bytes
@@ -297,7 +302,11 @@ impl ThumbnailCache {
}
/// Set the cache limit in bytes
pub async fn set_limit(&self, db: Arc<RusqliteService>, limit_bytes: u64) -> Result<(), String> {
pub async fn set_limit(
&self,
db: Arc<RusqliteService>,
limit_bytes: u64,
) -> Result<(), String> {
// Update database setting
let query = Query::with_params(
"INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
@@ -444,14 +453,24 @@ mod tests {
// Save a thumbnail
let data = b"fake image data";
let path = cache
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, Some(100), Some(100))
.save_thumbnail(
conn.clone(),
"item1",
"Primary",
"tag1",
data,
Some(100),
Some(100),
)
.await
.unwrap();
assert!(path.exists());
// Get cached path
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
let cached = cache
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
.await;
assert!(cached.is_some());
assert_eq!(cached.unwrap(), path);
}
@@ -461,7 +480,9 @@ mod tests {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let cached = cache.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1").await;
let cached = cache
.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
.await;
assert!(cached.is_none());
}
@@ -488,11 +509,15 @@ mod tests {
.unwrap();
// First item should be evicted
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
let cached = cache
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
.await;
assert!(cached.is_none());
// Second item should exist
let cached = cache.get_cached_path(conn.clone(), "item2", "Primary", "tag2").await;
let cached = cache
.get_cached_path(conn.clone(), "item2", "Primary", "tag2")
.await;
assert!(cached.is_some());
}