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
+88 -33
View File
@@ -13,7 +13,9 @@ use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::jellyfin::HttpClient;
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
};
/// Repository handle manager
pub struct RepositoryManager {
@@ -81,8 +83,13 @@ pub async fn repository_create(
// Create online repository wired to connectivity reporting
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
.with_connectivity(connectivity_reporter);
let online = OnlineRepository::new(
Arc::new(http_client),
server_url,
user_id.clone(),
access_token,
)
.with_connectivity(connectivity_reporter);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
@@ -151,12 +158,10 @@ pub async fn repository_get_libraries(
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching libraries...");
repo.as_ref().get_libraries()
.await
.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
repo.as_ref().get_libraries().await.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
}
/// Get items in a container (library, folder, album, etc.)
@@ -169,7 +174,8 @@ pub async fn repository_get_items(
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items(&parent_id, options)
repo.as_ref()
.get_items(&parent_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -183,7 +189,8 @@ pub async fn repository_get_item(
item_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_item(&item_id)
repo.as_ref()
.get_item(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -200,7 +207,8 @@ pub async fn repository_jray_actors_at(
t: f64,
) -> Result<Vec<crate::repository::JRayActor>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_jray_actors(&item_id, t)
repo.as_ref()
.get_jray_actors(&item_id, t)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -215,7 +223,8 @@ pub async fn repository_get_latest_items(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_latest_items(&parent_id, limit)
repo.as_ref()
.get_latest_items(&parent_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -235,7 +244,8 @@ pub async fn repository_get_resume_items(
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching resume items...");
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
repo.as_ref()
.get_resume_items(parent_id.as_deref(), limit)
.await
.map_err(|e| {
error!("[REPO] Error fetching resume items: {:?}", e);
@@ -253,7 +263,8 @@ pub async fn repository_get_next_up_episodes(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
repo.as_ref()
.get_next_up_episodes(series_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -267,7 +278,8 @@ pub async fn repository_get_recently_played_audio(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_recently_played_audio(limit)
repo.as_ref()
.get_recently_played_audio(limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -281,7 +293,8 @@ pub async fn repository_get_resume_movies(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_resume_movies(limit)
repo.as_ref()
.get_resume_movies(limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -296,7 +309,8 @@ pub async fn repository_get_rediscover_albums(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
repo.as_ref()
.get_rediscover_albums(parent_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -310,7 +324,8 @@ pub async fn repository_get_genres(
parent_id: Option<String>,
) -> Result<Vec<Genre>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_genres(parent_id.as_deref())
repo.as_ref()
.get_genres(parent_id.as_deref())
.await
.map_err(|e| format!("{:?}", e))
}
@@ -363,8 +378,7 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
let event = SearchUpdateEvent {
request_id,
result: merged,
@@ -376,7 +390,10 @@ pub async fn repository_search(
Err(e) => {
// Server failed — the cache results are already on screen, so
// just log. (Offline / unreachable server falls here.)
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
warn!(
"[Search] Server search failed, keeping cache results: {:?}",
e
);
}
}
});
@@ -393,7 +410,8 @@ pub async fn repository_get_playback_info(
item_id: String,
) -> Result<PlaybackInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playback_info(&item_id)
repo.as_ref()
.get_playback_info(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -421,6 +439,31 @@ pub async fn repository_get_video_stream_url(
.map_err(|e| format!("{:?}", e))
}
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
///
/// TRACES: UR-040 | JA-032 | UT-061
#[tauri::command]
#[specta::specta]
pub async fn repository_get_audio_only_stream_url_for_video(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
media_source_id: Option<String>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_audio_only_stream_url_for_video(
&item_id,
media_source_id.as_deref(),
start_time_seconds,
audio_stream_index,
)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get audio stream URL for a track
#[tauri::command]
#[specta::specta]
@@ -489,7 +532,8 @@ pub async fn repository_report_playback_start(
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_start(&item_id, position_ticks)
repo.as_ref()
.report_playback_start(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -504,7 +548,8 @@ pub async fn repository_report_playback_progress(
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_progress(&item_id, position_ticks)
repo.as_ref()
.report_playback_progress(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -519,7 +564,8 @@ pub async fn repository_report_playback_stopped(
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
repo.as_ref()
.report_playback_stopped(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -551,7 +597,9 @@ pub fn repository_get_subtitle_url(
format: String,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
Ok(repo
.as_ref()
.get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
}
/// Get video download URL with quality preset
@@ -566,7 +614,9 @@ pub fn repository_get_video_download_url(
media_source_id: Option<String>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
Ok(repo
.as_ref()
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
}
/// Mark an item as favorite
@@ -578,7 +628,8 @@ pub async fn repository_mark_favorite(
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().mark_favorite(&item_id)
repo.as_ref()
.mark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -592,7 +643,8 @@ pub async fn repository_unmark_favorite(
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().unmark_favorite(&item_id)
repo.as_ref()
.unmark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -606,7 +658,8 @@ pub async fn repository_get_person(
person_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_person(&person_id)
repo.as_ref()
.get_person(&person_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -621,7 +674,8 @@ pub async fn repository_get_items_by_person(
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items_by_person(&person_id, options)
repo.as_ref()
.get_items_by_person(&person_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -636,7 +690,8 @@ pub async fn repository_get_similar_items(
limit: Option<usize>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_similar_items(&item_id, limit)
repo.as_ref()
.get_similar_items(&item_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}