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
+26 -16
View File
@@ -128,7 +128,9 @@ impl DatabaseService for RusqliteService {
async fn execute(&self, query: Query) -> DbResult<usize> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
execute_query(&conn, query)
})
.await
@@ -139,7 +141,9 @@ impl DatabaseService for RusqliteService {
let conn = Arc::clone(&self.conn);
let sql = sql.to_string();
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
conn.execute_batch(&sql)
.map_err(|e| format!("Execute batch failed: {}", e))
})
@@ -154,7 +158,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_one(&conn, query, mapper)
})
.await
@@ -168,7 +174,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_optional(&conn, query, mapper)
})
.await
@@ -182,7 +190,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_many(&conn, query, mapper)
})
.await
@@ -196,7 +206,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
@@ -224,7 +236,9 @@ impl DatabaseService for RusqliteService {
async fn last_insert_rowid(&self) -> DbResult<i64> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
Ok(conn.last_insert_rowid())
})
.await
@@ -255,7 +269,9 @@ where
{
match query_one(conn, query, mapper) {
Ok(value) => Ok(Some(value)),
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => Ok(None),
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => {
Ok(None)
}
Err(e) => Err(e),
}
}
@@ -325,10 +341,7 @@ mod tests {
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
let query = Query::new("SELECT name FROM test WHERE id = 1");
let name: String = service
.query_one(query, |row| row.get(0))
.await
.unwrap();
let name: String = service.query_one(query, |row| row.get(0)).await.unwrap();
assert_eq!(name, "Bob");
}
@@ -346,10 +359,7 @@ mod tests {
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
let query = Query::new("SELECT name FROM test ORDER BY id");
let names: Vec<String> = service
.query_many(query, |row| row.get(0))
.await
.unwrap();
let names: Vec<String> = service.query_many(query, |row| row.get(0)).await.unwrap();
assert_eq!(names, vec!["Alice", "Bob"]);
}
+36 -29
View File
@@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use rusqlite::{Connection, Result as SqliteResult};
use schema::MIGRATIONS;
pub use db_service::{DatabaseService, RusqliteService};
use schema::MIGRATIONS;
/// Database connection wrapper with thread-safe access
pub struct Database {
@@ -113,10 +113,7 @@ impl Database {
match conn.execute_batch(sql) {
Ok(_) => {
info!("Successfully applied migration: {}", name);
match conn.execute(
"INSERT INTO _migrations (name) VALUES (?1)",
[name],
) {
match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
Ok(_) => debug!("Recorded migration: {}", name),
Err(e) => {
error!("Failed to record migration {}: {}", name, e);
@@ -264,9 +261,11 @@ mod tests {
.unwrap();
let name: String = conn
.query_row("SELECT name FROM servers WHERE id = ?1", ["server1"], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT name FROM servers WHERE id = ?1",
["server1"],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(name, "Updated Server");
@@ -275,7 +274,9 @@ mod tests {
.unwrap();
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| row.get(0))
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| {
row.get(0)
})
.unwrap();
assert_eq!(count, 0);
}
@@ -313,16 +314,15 @@ mod tests {
assert_eq!(is_active, 1);
// Update is_active
conn.execute(
"UPDATE users SET is_active = 0 WHERE id = ?1",
["user1"],
)
.unwrap();
conn.execute("UPDATE users SET is_active = 0 WHERE id = ?1", ["user1"])
.unwrap();
let is_active: i32 = conn
.query_row("SELECT is_active FROM users WHERE id = ?1", ["user1"], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT is_active FROM users WHERE id = ?1",
["user1"],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(is_active, 0);
}
@@ -348,9 +348,11 @@ mod tests {
// Verify user exists
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM users WHERE server_id = ?1", ["server1"], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT COUNT(*) FROM users WHERE server_id = ?1",
["server1"],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
@@ -360,7 +362,9 @@ mod tests {
// User should be deleted via CASCADE
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| row.get(0))
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| {
row.get(0)
})
.unwrap();
assert_eq!(count, 0);
}
@@ -677,15 +681,16 @@ mod tests {
// Initially both users are active (simulating the old bug)
let active_count: i32 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT COUNT(*) FROM users WHERE is_active = 1",
[],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(active_count, 2);
// Now simulate setting user1 as active (global deactivation)
conn.execute("UPDATE users SET is_active = 0", [])
.unwrap();
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
conn.execute(
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
["user1"],
@@ -694,9 +699,11 @@ mod tests {
// Only one user should be active now
let active_count: i32 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT COUNT(*) FROM users WHERE is_active = 1",
[],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(active_count, 1);