Compare commits

..
10 Commits
Author SHA1 Message Date
dtourolle e1e50d51e0 Use different app logo
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m1s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m52s
Build & Release / Build Linux (push) Successful in 16m22s
Build & Release / Build Android (push) Successful in 19m13s
Build & Release / Create Release (push) Successful in 10s
2026-06-27 17:43:08 +02:00
dtourolle 7d7f27aa10 feat(library and playback): Support for serverside channel plugins and hls streaming 2026-06-27 17:25:57 +02:00
dtourolle f1d25c4f4d Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
2026-06-26 19:27:37 +02:00
dtourolle ff8f35084b Merge pull request 'feat(library): genre sliders, artist links, and navigation utils' (#5) from feat/library-genre-sliders-and-nav-utils into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m54s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m38s
Build & Release / Build Linux (push) Successful in 16m2s
Build & Release / Build Android (push) Successful in 18m54s
Build & Release / Create Release (push) Successful in 11s
Reviewed-on: #5
2026-06-25 21:52:13 +00:00
dtourolle 4634ed595c fix(remote playback): Move audio between remote players
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m37s
2026-06-25 23:39:01 +02:00
dtourolle 6836ce79c8 fix(Remote playback): kludge to scrub after stream move
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m18s
2026-06-25 21:31:39 +02:00
dtourolle 2811e1b7ca fix: several small fixes
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m26s
2026-06-25 20:02:01 +02:00
dtourolle 1836615dc0 feat(library): genre sliders, artist links, and navigation utils
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s
- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
2026-06-25 19:18:06 +02:00
dtourolle 62874564ff Merge pull request 'feat(library): focused music/TV/movie landing screens + self-draining download queue' (#4) from feat/library-screens-and-download-queue into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 2m44s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
Build & Release / Run Tests (push) Successful in 2m36s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m41s
Build & Release / Build Linux (push) Successful in 16m23s
Build & Release / Build Android (push) Successful in 18m39s
Build & Release / Create Release (push) Successful in 12s
Reviewed-on: #4
2026-06-24 20:07:34 +00:00
dtourolle 17a35573a0 feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
  horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
  listened to in a while") albums via a new repository method across
  online/offline/hybrid repos plus the repository_get_rediscover_albums
  command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
  index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.

Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
  persist the resolved stream URL + target dir on each row (migration
  017), and the pump starts up to max_concurrent and drains the rest
  automatically as slots free, instead of the frontend silently dropping
  items past the concurrency limit. Album/series/season buttons now
  enqueue rather than calling start_download directly.

Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
  cache+server union via a request-id-tagged search-event, so superseded
  queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
2026-06-24 20:44:17 +02:00
106 changed files with 4476 additions and 522 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
# JellyTau
<h1 align="center">
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
JellyTau
</h1>
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

+8 -1
View File
@@ -2028,6 +2028,7 @@ dependencies = [
"tokio",
"tokio-rusqlite",
"tokio-util",
"urlencoding",
"uuid",
]
@@ -4959,6 +4960,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -5281,7 +5288,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -33,6 +33,7 @@ rand = "0.8"
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
urlencoding = "2"
futures-util = "0.3"
async-trait = "0.1"
@@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
/** Current media ID being played */
private var currentMediaId: String? = null
/**
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
* end of a transcoded stream), which would otherwise notify the backend
* twice and, for example, decrement the sleep-timer episode counter twice.
* Reset whenever new media is loaded.
*/
private var endedNotified = false
/** Current media metadata for notification updates */
private var currentTitle: String = ""
private var currentArtist: String = ""
@@ -171,6 +180,12 @@ class JellyTauPlayer(private val appContext: Context) {
// Create ExoPlayer with audio focus handling
exoPlayer = ExoPlayer.Builder(appContext)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
// Pause when the audio output is removed (wired headphones unplugged or
// Bluetooth device disconnected). ExoPlayer listens for the system
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
// the lockscreen notification in sync automatically.
.setHandleAudioBecomingNoisy(true)
.build()
// Set up player listener
@@ -202,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) {
// Playback completed
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
stopPositionUpdates()
// Only notify the backend once per loaded media. ExoPlayer
// can re-enter STATE_ENDED, which would double-count things
// like the sleep-timer episode counter.
if (!endedNotified) {
endedNotified = true
nativeOnPlaybackEnded()
} else {
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
}
}
Player.STATE_BUFFERING -> {
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
@@ -316,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun load(url: String, mediaId: String) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
@@ -546,6 +570,7 @@ class JellyTauPlayer(private val appContext: Context) {
) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
// Store metadata for notification updates
currentTitle = title
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+328 -42
View File
@@ -2,8 +2,9 @@
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::State;
use tauri::{Manager, State};
use log::{debug, error, info, warn};
use crate::download::{DownloadInfo, DownloadManager};
@@ -818,9 +819,7 @@ pub async fn start_download(
stream_url: String,
target_dir: String,
) -> Result<(), String> {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent;
use std::path::PathBuf;
use tauri::Emitter;
debug!("start_download called for download_id: {}", download_id);
@@ -906,16 +905,27 @@ pub async fn start_download(
}
};
// Update status to downloading and save file_size if we got it
// Update status to downloading and save file_size if we got it.
// Also persist the resolved stream URL + target dir so the queue pump can
// restart/resume this download by itself if needed.
let update_query = if let Some(size) = file_size_from_server {
Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ? WHERE id = ?",
vec![QueryParam::Int64(size), QueryParam::Int64(download_id)],
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::Int64(size),
QueryParam::String(stream_url.clone()),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
)
} else {
Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(download_id)],
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::String(stream_url.clone()),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
)
};
@@ -936,29 +946,304 @@ pub async fn start_download(
// Build target path
let target_path = PathBuf::from(&target_dir).join(&file_path);
// Create download task
let task = DownloadTask {
url: stream_url,
target_path: target_path.clone(),
};
// Start download in background
let app_clone = app.clone();
let item_id_clone = item_id.clone();
// Get a clone of the active downloads Arc for unregistering later
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
// Run the worker in the background; on completion/failure it frees the slot
// and pumps the next pending download.
spawn_download_worker(
app.clone(),
download_id,
item_id,
stream_url,
target_path,
active_downloads,
);
Ok(())
}
/// Enqueue a download with its resolved stream URL, then let the queue pump
/// start it (or a higher-priority pending item) when a slot is free.
///
/// Unlike [`start_download`], this never errors when the concurrency limit is
/// reached: the URL is persisted on the row and the pump will pick it up once a
/// slot frees. This is the path bulk operations (album/series/season) use so
/// every queued item eventually downloads without the frontend re-issuing it.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_download(
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
app: tauri::AppHandle,
download_id: i64,
stream_url: String,
target_dir: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Persist the resolved URL/dir and mark the row pending so the pump can
// start it. We don't flip to 'downloading' here — the pump owns that.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir),
QueryParam::Int64(download_id),
],
);
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
// Kick the pump: it will start as many pending downloads as there are slots.
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(())
}
/// Enqueue a batch of already-queued video downloads, resolving each one's
/// transcode URL from the repository using the `quality_preset` stored on the
/// row. Then let the pump start them subject to the concurrency limit.
///
/// This is the bulk video path (series/season): `download_series`/
/// `download_season` insert the rows, then this resolves URLs and enqueues them
/// so they actually start. Resolving server-side avoids round-tripping every
/// episode URL through the frontend.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_video_downloads(
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
app: tauri::AppHandle,
handle: String,
download_ids: Vec<i64>,
target_dir: String,
) -> Result<(), String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
for download_id in download_ids {
// Read the item + quality preset for this queued download.
let info_query = Query::with_params(
"SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
vec![QueryParam::Int64(download_id)],
);
let (item_id, quality): (String, String) = match db_service
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
.await
{
Ok(row) => row,
Err(e) => {
warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
continue;
}
};
// Build the transcode URL (pure URL builder, no server round-trip).
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
);
if let Err(e) = db_service.execute(update_query).await {
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
}
}
// Pump once: starts up to max_concurrent, the rest drain as slots free.
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(())
}
/// Start as many pending downloads as there are free concurrency slots.
///
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
/// (FIFO within a priority), registers each, flips it to `downloading`, and
/// spawns a worker. Each spawned worker calls this again on completion/failure,
/// so the queue drains itself without any frontend involvement.
async fn pump_download_queue(
app: tauri::AppHandle,
db_service: Arc<crate::storage::db_service::RusqliteService>,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::events::DownloadEvent;
use tauri::Emitter;
let max_concurrent = {
let manager = app.state::<DownloadManagerWrapper>();
let manager = match manager.0.lock() {
Ok(m) => m,
Err(e) => {
error!("[pump] Failed to lock download manager: {}", e);
return;
}
};
manager.max_concurrent()
};
loop {
// How many slots are free right now?
let free_slots = {
let active = match active_downloads.lock() {
Ok(a) => a,
Err(e) => {
error!("[pump] Failed to lock active downloads: {}", e);
return;
}
};
max_concurrent.saturating_sub(active.len())
};
if free_slots == 0 {
return;
}
// Find the next pending, startable download (has a stream URL). Exclude
// anything already registered as active to avoid double-starting.
let next_query = Query::with_params(
"SELECT id, item_id, file_path, stream_url, target_dir
FROM downloads
WHERE status = 'pending'
AND stream_url IS NOT NULL
AND target_dir IS NOT NULL
ORDER BY priority DESC, queued_at ASC",
vec![],
);
let candidates: Vec<(i64, String, String, String, String)> = match db_service
.query_many(next_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
})
.await
{
Ok(rows) => rows,
Err(e) => {
error!("[pump] Failed to query pending downloads: {}", e);
return;
}
};
// Pick the first candidate not already active.
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
active_downloads
.lock()
.map(|active| !active.contains(id))
.unwrap_or(false)
});
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
Some(n) => n,
None => return, // Nothing pending to start
};
// Register the slot. If registration fails (race: another pump filled
// the last slot), stop — we'll be re-pumped when a slot frees.
{
let manager = app.state::<DownloadManagerWrapper>();
let manager = match manager.0.lock() {
Ok(m) => m,
Err(e) => {
error!("[pump] Failed to lock download manager: {}", e);
return;
}
};
if !manager.register_download(download_id) {
return;
}
info!(
"[pump] Download {} started. Active downloads: {}/{}",
download_id,
manager.active_count(),
manager.max_concurrent()
);
}
// Mark as downloading and stamp started_at.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(download_id)],
);
if let Err(e) = db_service.execute(update_query).await {
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
if let Ok(mut a) = active_downloads.lock() {
a.remove(&download_id);
}
continue;
}
// Emit started event so the UI flips the row.
let _ = app.emit(
"download-event",
DownloadEvent::Started {
download_id,
item_id: item_id.clone(),
},
);
let target_path = PathBuf::from(&target_dir).join(&file_path);
spawn_download_worker(
app.clone(),
download_id,
item_id,
stream_url,
target_path,
active_downloads.clone(),
);
}
}
/// Spawn the background worker for one download. On completion or failure it
/// unregisters the slot, emits the terminal event, and pumps the queue so the
/// next pending download starts automatically.
fn spawn_download_worker(
app: tauri::AppHandle,
download_id: i64,
item_id: String,
stream_url: String,
target_path: std::path::PathBuf,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent;
use tauri::Emitter;
let task = DownloadTask {
url: stream_url,
target_path: target_path.clone(),
};
tauri::async_runtime::spawn(async move {
debug!("Download task started for download_id: {}", download_id);
let worker = DownloadWorker::new();
// Progress callback that emits events to the frontend
let progress_app = app_clone.clone();
let progress_item_id = item_id_clone.clone();
let progress_app = app.clone();
let progress_item_id = item_id.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes
.filter(|&t| t > 0)
@@ -975,54 +1260,55 @@ pub async fn start_download(
let _ = progress_app.emit("download-event", event);
};
match worker.download(&task, on_progress).await {
Ok(result) => {
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
let result = worker.download(&task, on_progress).await;
// Unregister from download manager
// Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id);
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
}
// Emit completed event - the frontend will handle state updates
match result {
Ok(res) => {
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
let completed_event = DownloadEvent::Completed {
download_id,
item_id: item_id_clone,
item_id,
file_path: target_path.to_string_lossy().to_string(),
};
debug!("Emitting completed event: {:?}", completed_event);
debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default());
match app_clone.emit("download-event", completed_event) {
match app.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"),
Err(e) => error!(" Completed event emit failed: {:?}", e),
}
}
Err(e) => {
error!("Download failed: {:?}", e);
// Unregister from download manager
if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id);
debug!(" Unregistered failed download {}. Active downloads: {}", download_id, active.len());
}
// Emit failed event - the frontend will handle state updates
let failed_event = DownloadEvent::Failed {
download_id,
item_id: item_id_clone.clone(),
item_id,
error: e.to_string(),
};
debug!("Emitting failed event: {:?}", failed_event);
match app_clone.emit("download-event", failed_event) {
match app.emit("download-event", failed_event) {
Ok(_) => debug!(" Failed event emitted successfully"),
Err(e) => error!(" Failed event emit failed: {:?}", e),
}
}
}
});
Ok(())
// A slot just freed — start the next pending download (if any).
let db_service = {
let db = app.state::<DatabaseWrapper>();
let database = match db.0.lock() {
Ok(d) => d,
Err(e) => {
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
return;
}
};
Arc::new(database.service())
};
pump_download_queue(app.clone(), db_service, active_downloads).await;
});
}
/// Delete a completed download
+21 -3
View File
@@ -41,12 +41,30 @@ pub fn playback_mode_is_transferring(
pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String,
position: Option<f64>,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}",
session_id
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
session_id,
position
);
manager.0.transfer_to_remote(session_id).await
manager.0.transfer_to_remote(session_id, position).await
}
/// Set the transferring flag on the playback mode manager.
///
/// Used by the frontend remote->local flow to mark the whole two-step sequence
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
/// casting back to the remote session it's leaving. Always pair `true` with a
/// later `false` (including on error) so the flag can't stick.
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_set_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
transferring: bool,
) -> Result<(), String> {
manager.0.set_transferring(transferring);
Ok(())
}
/// Transfer playback from remote session back to local device
+98 -3
View File
@@ -224,6 +224,10 @@ pub struct PlayTracksRequest {
pub start_index: usize,
pub shuffle: bool,
pub context: PlayTracksContext,
/// Position (seconds) to resume the starting track from. Used when taking
/// over playback from a remote session so we don't restart from 0.
#[serde(default)]
pub start_position: Option<f64>,
}
/// Context information for track playback
@@ -1245,6 +1249,59 @@ pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
}
}
/// Start a freshly-built queue on the active remote session.
///
/// Used by the "play tracks"/"play album track" commands when we're in remote
/// mode: instead of starting local MPV playback, we cast the selected tracks to
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
/// play_on_session call, but for a brand-new selection (so there's no resume
/// position - playback starts from the chosen track's beginning).
///
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
/// if the selected track itself has no Jellyfin ID.
async fn play_selection_on_remote(
controller: &PlayerController,
session_id: &str,
media_items: &[MediaItem],
start_index: usize,
) -> Result<(), String> {
// Collect Jellyfin IDs, tracking where the selected track lands after any
// local-only items are dropped.
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
for (i, item) in media_items.iter().enumerate() {
if let Some(id) = item.jellyfin_id() {
if i == start_index {
adjusted_index = Some(jellyfin_ids.len());
}
jellyfin_ids.push(id.to_string());
}
}
let start_index = adjusted_index
.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
if jellyfin_ids.is_empty() {
return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
}
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Fresh selection: start from the beginning of the chosen track.
client
.play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
.await
.map_err(|e| format!("Failed to start playback on remote session: {}", e))
}
/// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command]
@@ -1254,6 +1311,7 @@ pub async fn player_play_album_track(
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> {
@@ -1376,11 +1434,24 @@ pub async fn player_play_album_track(
session_mgr.start_audio_session(first_item.clone());
}
// Play the queue
let controller = player.0.lock().await;
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. We still load the queue locally (below) so
// the queue/context stay in sync for the UI and for transferring back.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
controller.set_queue(media_items, start_index).map_err(|e| e.to_string())?;
} else {
controller
.play_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
}
// Set the queue context for remote transfer
{
@@ -1422,6 +1493,7 @@ pub async fn player_play_tracks(
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayTracksRequest,
) -> Result<PlayerStatus, String> {
@@ -1529,10 +1601,33 @@ pub async fn player_play_tracks(
session_mgr.start_audio_session(first_item.clone());
}
// Play queue
let controller = player.0.lock().await;
controller.play_queue(media_items, request.start_index)
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. Skip this while a transfer is in flight: the
// transfer-to-local path calls this command to load the queue locally and
// the mode is still Remote until the transfer completes - routing it back to
// the remote would undo the transfer.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id }
if !playback_mode.0.is_transferring() =>
{
Some(session_id)
}
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
.await?;
controller
.set_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?;
} else {
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
}
// Set queue context
{
+90
View File
@@ -6,6 +6,7 @@
use tauri::State;
use super::PlayerStateWrapper;
use crate::jellyfin::client::LmsSyncGroup;
/// Play items on a remote Jellyfin session (casting)
#[tauri::command]
@@ -129,3 +130,92 @@ pub async fn remote_session_toggle_mute(
Err("Jellyfin client not configured".to_string())
}
}
// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
//
// The frontend addresses LMS players by MAC address, which it derives from a
// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
// plugin REST API via the configured JellyfinClient.
/// List current LMS sync groups.
#[tauri::command]
#[specta::specta]
pub async fn lms_get_sync_groups(
player: State<'_, PlayerStateWrapper>,
) -> Result<Vec<LmsSyncGroup>, String> {
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_get_sync_groups().await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
/// `slave_macs` zones join it in sync.
#[tauri::command]
#[specta::specta]
pub async fn lms_create_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
slave_macs: Vec<String>,
) -> Result<(), String> {
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_create_sync_group(&master_mac, slave_macs).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Remove a single LMS zone from its sync group (decouple one player).
#[tauri::command]
#[specta::specta]
pub async fn lms_unsync_player(
player: State<'_, PlayerStateWrapper>,
mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Decoupling zone {}", mac);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_unsync_player(&mac).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
#[tauri::command]
#[specta::specta]
pub async fn lms_dissolve_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Dissolving group with master {}", master_mac);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_dissolve_sync_group(&master_mac).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
+117 -4
View File
@@ -7,8 +7,9 @@ use crate::utils::lock::MutexSafe;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use tauri::State;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::jellyfin::HttpClient;
@@ -259,6 +260,21 @@ pub async fn repository_get_resume_movies(
.map_err(|e| format!("{:?}", e))
}
/// Get albums the user hasn't listened to recently ("rediscover")
#[tauri::command]
#[specta::specta]
pub async fn repository_get_rediscover_albums(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get genres for a library
#[tauri::command]
#[specta::specta]
@@ -273,19 +289,73 @@ pub async fn repository_get_genres(
.map_err(|e| format!("{:?}", e))
}
/// Tauri event name carrying the merged (cache + server) search results.
pub const SEARCH_EVENT_NAME: &str = "search-event";
/// Payload for the deferred, merged search results pushed to the frontend.
///
/// `request_id` matches the value the frontend passed to `repository_search`,
/// letting it discard updates from queries that have since been superseded.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchUpdateEvent {
pub request_id: u32,
pub result: SearchResult,
}
/// Search for items
#[tauri::command]
#[specta::specta]
pub async fn repository_search(
app: AppHandle,
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
query: String,
options: Option<SearchOptions>,
request_id: u32,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().search(&query, options)
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
.search_cache_only(&query, options.clone())
.await
.map_err(|e| format!("{:?}", e))
.unwrap_or_else(|e| {
debug!("[Search] Cache search miss/timeout: {:?}", e);
SearchResult {
items: Vec::new(),
total_record_count: 0,
}
});
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
let repo_bg = repo.clone();
let cache_for_merge = cache_result.clone();
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 event = SearchUpdateEvent {
request_id,
result: merged,
};
if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
error!("[Search] Failed to emit search update: {}", e);
}
}
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);
}
}
});
Ok(cache_result)
}
/// Get playback info for an item
@@ -340,6 +410,49 @@ pub async fn repository_get_audio_stream_url(
.map_err(|e| format!("{:?}", e))
}
/// Get Live TV channels (broadcast / IPTV) for browsing
#[tauri::command]
#[specta::specta]
pub async fn repository_get_live_tv_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_live_tv_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Get the root list of plugin "Channels"
#[tauri::command]
#[specta::specta]
pub async fn repository_get_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Open a live stream for a Live TV channel / live item
#[tauri::command]
#[specta::specta]
pub async fn repository_open_live_stream(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<LiveStreamInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.open_live_stream(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start
#[tauri::command]
#[specta::specta]
+1
View File
@@ -82,6 +82,7 @@ impl ConnectivityReporter {
/// Current reachability as seen by this reporter (shared with the monitor
/// and the UI). Useful for callers that want to branch on connectivity.
#[allow(dead_code)] // public API; currently only exercised by cross-module tests
pub async fn is_reachable(&self) -> bool {
self.status.read().await.is_server_reachable
}
+98 -9
View File
@@ -284,22 +284,35 @@ impl JellyfinClient {
}
/// Seek on a remote session
///
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
/// body (as we used to) is silently ignored and the remote never seeks.
pub async fn session_seek(
&self,
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
struct SeekRequest {
seek_position_ticks: i64,
let url = format!(
"{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
self.config.server_url, session_id, position_ticks
);
let response = self.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
let request = SeekRequest {
seek_position_ticks: position_ticks,
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
Ok(())
}
/// Send a full GeneralCommand to a remote session.
@@ -371,6 +384,82 @@ impl JellyfinClient {
let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
}
// --- JellyLMS multi-room sync groups -----------------------------------
//
// The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
// players ("zones") into synchronized multi-room sync groups. Players are
// addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
// stripping the `lms-` prefix off the session's device id (see
// LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
// with deviceId = "lms-{MacAddress}").
/// List current LMS sync groups.
pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
self.get("/JellyLms/SyncGroups").await
}
/// Fuse LMS zones: create a sync group with `master_mac` as the sync master
/// and `slave_macs` joining it. The master keeps playing; slaves follow.
pub async fn lms_create_sync_group(
&self,
master_mac: &str,
slave_macs: Vec<String>,
) -> Result<(), String> {
let payload = serde_json::json!({
"MasterMac": master_mac,
"SlaveMacs": slave_macs,
});
self.post("/JellyLms/SyncGroups", &payload).await
}
/// Remove a single LMS player from whatever sync group it's in.
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
}
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
async fn delete(&self, endpoint: &str) -> Result<(), String> {
let url = format!("{}{}", self.config.server_url, endpoint);
log::debug!("[JellyfinClient] DELETE {}", endpoint);
let response = self.http_client
.delete(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
Ok(())
}
}
/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
///
/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
/// follow it in lockstep.
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LmsSyncGroup {
#[serde(alias = "MasterMac")]
pub master_mac: String,
#[serde(default, alias = "MasterName")]
pub master_name: String,
#[serde(default, alias = "SlaveMacs")]
pub slave_macs: Vec<String>,
#[serde(default, alias = "SlaveNames")]
pub slave_names: Vec<String>,
}
/// Default value for supports_remote_control when missing from API
+28 -4
View File
@@ -28,7 +28,7 @@ use commands::{
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
get_album_affinity_status,
mark_download_completed, mark_download_failed, start_download,
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
pin_item, unpin_item, is_item_pinned,
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
@@ -52,11 +52,13 @@ use commands::{
// Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
playback_mode_get_remote_status,
// Playback reporting commands
playback_reporter_init, playback_reporter_destroy,
@@ -96,8 +98,10 @@ use commands::{
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_get_latest_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
@@ -355,6 +359,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
// Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
.events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
@@ -413,6 +420,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
remote_session_seek,
remote_session_set_volume,
remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_create_sync_group,
lms_unsync_player,
lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint,
sessions_poll_now,
@@ -423,6 +435,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
playback_mode_transfer_to_remote,
playback_mode_get_remote_status,
playback_mode_transfer_to_local,
playback_mode_set_transferring,
// Playback reporting commands
playback_reporter_init,
playback_reporter_destroy,
@@ -493,6 +506,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
mark_download_completed,
mark_download_failed,
start_download,
enqueue_download,
enqueue_video_downloads,
get_download_manager_stats,
set_max_concurrent_downloads,
get_smart_cache_stats,
@@ -552,11 +567,15 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_next_up_episodes,
repository_get_recently_played_audio,
repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres,
repository_search,
repository_get_playback_info,
repository_get_video_stream_url,
repository_get_audio_stream_url,
repository_get_live_tv_channels,
repository_get_channels,
repository_open_live_stream,
repository_report_playback_start,
repository_report_playback_progress,
repository_report_playback_stopped,
@@ -597,11 +616,17 @@ pub fn run() {
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.setup(|app| {
.invoke_handler(invoke_handler)
.setup(move |app| {
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@@ -841,7 +866,6 @@ pub fn run() {
info!("[INIT] Application setup completed successfully");
Ok(())
})
.invoke_handler(builder.invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+153 -11
View File
@@ -20,6 +20,28 @@ pub enum PlaybackMode {
Idle,
}
/// Number of Jellyfin ticks per second (100ns units).
const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// Below this many seconds we treat the position as "at the start" and don't
/// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start.
///
/// Pure helper so the resume-position math is unit-testable without a remote
/// session or HTTP. The *source* of `position_seconds` matters too: callers
/// must pass the live backend position (`PlayerController::position()`), not the
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
if position_seconds > RESUME_THRESHOLD_SECONDS {
Some((position_seconds * TICKS_PER_SECOND) as i64)
} else {
None
}
}
/// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@@ -59,6 +81,19 @@ impl PlaybackModeManager {
self.is_transferring.load(Ordering::Relaxed)
}
/// Set the transferring flag directly.
///
/// The remote->local transfer is driven from the frontend in two steps
/// (`player_play_tracks` to start local playback, then
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
/// routing depends on this flag: while it's set, `player_play_tracks` plays
/// locally instead of casting back to the remote session. The frontend must
/// raise the flag *before* that first call and lower it when the sequence is
/// done (or aborts), so it can't be left stuck on.
pub fn set_transferring(&self, transferring: bool) {
self.is_transferring.store(transferring, Ordering::Relaxed);
}
/// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback
@@ -159,7 +194,11 @@ impl PlaybackModeManager {
}
/// Transfer playback from local device to remote Jellyfin session
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> {
pub async fn transfer_to_remote(
&self,
session_id: String,
position_override: Option<f64>,
) -> Result<(), String> {
debug!("[PlaybackMode] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id);
log::info!(
@@ -173,7 +212,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer
let result = self.transfer_to_remote_inner(&session_id).await;
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
@@ -181,12 +220,24 @@ impl PlaybackModeManager {
result
}
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> {
async fn transfer_to_remote_inner(
&self,
session_id: &str,
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
// position. Capture it so we can resume there and stop it afterwards.
let previous_remote_session = match self.get_mode() {
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
_ => None,
};
// Get current player state and queue context
let (queue_ids, current_index, position_seconds, queue_context) = {
let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await;
@@ -195,7 +246,6 @@ impl PlaybackModeManager {
let queue_arc = player.queue();
let queue = queue_arc.lock_safe();
let state = player.state();
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
@@ -210,7 +260,19 @@ impl PlaybackModeManager {
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
let position = state.position().unwrap_or(0.0);
// Prefer the frontend-supplied position when available. The backend
// position is unreliable as a transfer source: on Linux, *video* plays
// in the HTML5 <video> element and the MPV backend is never loaded, so
// PlayerController::position() is always 0; only the frontend knows the
// true position. We fall back to the live backend position (correct for
// Linux audio via MPV) when the frontend doesn't pass one.
let position = match position_override {
Some(p) => {
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
p
}
None => player.position(),
};
let context = queue.context().clone();
log::info!(
@@ -272,12 +334,48 @@ impl PlaybackModeManager {
}
};
// Calculate position in ticks
let start_position_ticks = if position_seconds > 0.5 {
Some((position_seconds * 10_000_000.0) as i64)
// Remote -> remote switch: take the current track and position from the
// session we're leaving, since the local player is idle and reports 0.
if let Some(ref prev_session_id) = previous_remote_session {
log::info!(
"[PlaybackMode] Remote->remote switch; reading state from previous session {}",
prev_session_id
);
match client.get_session(prev_session_id).await {
Ok(Some(session)) => {
// Resume at the previous session's position.
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
position_seconds = ticks as f64 / TICKS_PER_SECOND;
log::info!(
"[PlaybackMode] Using previous remote position: {:.2}s",
position_seconds
);
}
// Resume on whichever track the previous session reached.
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
log::info!(
"[PlaybackMode] Previous session is on track {} (queue index {})",
now_id,
idx
);
current_index = idx;
} else {
None
};
log::warn!(
"[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
now_id,
current_index
);
}
}
}
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
}
}
// Calculate position in ticks (from the live position read above)
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
@@ -392,6 +490,33 @@ impl PlaybackModeManager {
return Err("Remote session did not load track in time".to_string());
}
// Resume at the right position. We send StartPositionTicks in the play
// command above, but some Jellyfin client/server combinations ignore it
// and start from 0. Now that the track is confirmed loaded, issue an
// explicit seek as well (mirrors how the local resume path works). This
// is the reliable mechanism; StartPositionTicks is best-effort.
if let Some(ticks) = start_position_ticks {
log::info!(
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
ticks
);
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
// Non-fatal: the track is already playing, just not at the
// resume point. Log and continue rather than failing the transfer.
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
}
}
// Remote -> remote switch: stop the session we just left so we don't end
// up with two devices playing at once. Do this only after the new session
// is confirmed playing, so a failure here doesn't leave us with silence.
if let Some(prev_session_id) = previous_remote_session {
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
}
}
// Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{
@@ -577,6 +702,23 @@ mod tests {
);
}
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
#[test]
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
assert_eq!(start_position_ticks_from_seconds(0.5), None);
// Just past the threshold resumes rather than restarting.
assert!(start_position_ticks_from_seconds(0.6).is_some());
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType};
+6 -1
View File
@@ -678,7 +678,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
match controller.lock().await.on_playback_ended().await {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
+13 -6
View File
@@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe;
use log::error;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode};
@@ -20,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
/// state machine transitions.
///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
// NOTE: fields are intentionally snake_case on the wire. specta generates the
// TypeScript bindings with snake_case field names (it does not apply serde's
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
// serde emit camelCase payloads that no longer match the generated schema —
// tauri-specta then silently drops those events (e.g. state_changed,
// queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
@@ -113,9 +121,6 @@ pub enum PlayerStatusEvent {
},
}
/// Tauri event name for player status events
pub const PLAYER_EVENT_NAME: &str = "player-event";
/// Trait for emitting player events to the frontend.
///
/// This abstraction allows backends to emit events without depending
@@ -141,7 +146,9 @@ impl TauriEventEmitter {
impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
// Emitted via the tauri-specta Event trait so the payload shape and event
// name match the generated TypeScript bindings (events.playerStatusEvent).
if let Err(e) = Event::emit(&event, &self.app_handle) {
error!("Failed to emit player event: {}", e);
}
}
+85 -1
View File
@@ -311,7 +311,27 @@ impl PlayerController {
/// Set the queue and start playing from the specified index
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index);
self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start
self.reset_autoplay_count();
@@ -324,11 +344,37 @@ impl PlayerController {
// Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
}
Ok(())
}
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
@@ -1333,6 +1379,44 @@ mod tests {
}
}
/// Resuming a queue at a position seeks the starting track immediately.
/// Regression guard for taking over a remote session: the local player must
/// pick up where the remote left off, not restart from 0.
#[test]
fn test_play_queue_from_resumes_at_position() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
}
/// A None / near-zero start position starts the track from the beginning.
#[test]
fn test_play_queue_from_without_position_starts_at_zero() {
let controller = PlayerController::default();
controller
.play_queue_from(create_test_items(2), 0, None)
.unwrap();
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
controller
.play_queue_from(create_test_items(2), 0, Some(0.2))
.unwrap();
assert_eq!(
controller.position(),
0.0,
"Sub-threshold resume position is ignored (starts at 0)"
);
}
#[test]
fn test_seek_to_zero() {
let controller = PlayerController::default();
+6 -1
View File
@@ -61,7 +61,12 @@ pub enum PlayerState {
}
impl PlayerState {
/// Get the current playback position if available
/// Get the current playback position if available.
///
/// Note: this is the position snapshot embedded in the state at the last
/// state transition, not the live backend position. For an up-to-date
/// value use `PlayerController::position()`.
#[allow(dead_code)]
pub fn position(&self) -> Option<f64> {
match self {
PlayerState::Playing { position, .. } => Some(*position),
+223
View File
@@ -57,6 +57,81 @@ impl HybridRepository {
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
}
/// Search only the local SQLite cache (downloaded content).
///
/// Fast (100ms timeout) — used to render instant results before the server
/// responds. Returns an empty result rather than erroring on timeout so the
/// caller can still fall through to the server.
pub async fn search_cache_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
let query = query.to_string();
self.cache_with_timeout(async move { offline.search(&query, options).await })
.await
}
/// Search only the live Jellyfin server (full library).
pub async fn search_server_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
self.online.search(query, options).await
}
/// Merge cache and server search results into a single de-duplicated list.
///
/// Ordering: local (cached/downloaded) items first, then server-only items
/// appended. On a duplicate `id`, the server's item wins (fresher, more
/// complete metadata) but keeps the local item's earlier position.
pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
use std::collections::HashMap;
// Index server items by id so we can (a) override duplicates with the
// server's metadata and (b) know which server items are brand new.
let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
for item in server.items {
if !server_by_id.contains_key(&item.id) {
server_order.push(item.id.clone());
}
server_by_id.insert(item.id.clone(), item);
}
let mut items: Vec<MediaItem> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
// Local items first, in their original order. If the server also
// returned this item, take the server's copy (newer metadata).
for local in cache.items {
if !seen.insert(local.id.clone()) {
continue;
}
match server_by_id.remove(&local.id) {
Some(server_item) => items.push(server_item),
None => items.push(local),
}
}
// Then append server-only items, preserving the server's order.
for id in server_order {
if let Some(server_item) = server_by_id.remove(&id) {
if seen.insert(id) {
items.push(server_item);
}
}
}
let total_record_count = items.len();
SearchResult {
items,
total_record_count,
}
}
/// Cache-first query: try cache, fall back to server on miss.
///
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
@@ -274,6 +349,27 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await
}
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let parent_id_owned = parent_id.map(|s| s.to_string());
let parent_id_clone = parent_id_owned.clone();
let cache_future = self.cache_with_timeout(async move {
offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await
});
let server_future = async move {
online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await
};
self.parallel_race(cache_future, server_future).await
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
@@ -319,6 +415,21 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV requires server communication - delegate to online repository
self.online.get_live_tv_channels().await
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Plugin channels require server communication - delegate to online repository
self.online.get_channels().await
}
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Opening a live stream requires server communication - delegate to online
self.online.open_live_stream(item_id).await
}
async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
// Playback reporting goes directly to server
self.online.report_playback_start(item_id, position_ticks).await
@@ -602,6 +713,14 @@ mod tests {
unimplemented!()
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!()
}
@@ -618,6 +737,18 @@ mod tests {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!()
}
@@ -759,6 +890,14 @@ mod tests {
unimplemented!()
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!()
}
@@ -775,6 +914,18 @@ mod tests {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!()
}
@@ -864,6 +1015,7 @@ mod tests {
id: id.to_string(),
name: name.to_string(),
item_type: "Movie".to_string(),
is_folder: false,
server_id: "test-server".to_string(),
parent_id: Some("parent-123".to_string()),
library_id: Some("library-456".to_string()),
@@ -1058,4 +1210,75 @@ mod tests {
};
assert!(result_with_items.has_content(), "Result with items should have content");
}
#[test]
fn test_merge_search_local_first_then_server_appended() {
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("c", "Server C"),
create_test_item("d", "Server D"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
// Local items first (in order), then server-only items appended.
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c", "d"]);
assert_eq!(merged.total_record_count, 4);
}
#[test]
fn test_merge_search_dedupes_with_server_winning() {
// "b" appears in both. Server metadata should win, but the item keeps
// its earlier (local) position and is not duplicated.
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("b", "Server B (fresher)"),
create_test_item("c", "Server C"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept");
let b = merged.items.iter().find(|i| i.id == "b").unwrap();
assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict");
assert_eq!(merged.total_record_count, 3);
}
#[test]
fn test_merge_search_handles_empty_sides() {
let only_server = HybridRepository::merge_search_results(
SearchResult { items: vec![], total_record_count: 0 },
SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 },
);
assert_eq!(only_server.items.len(), 1);
assert_eq!(only_server.items[0].id, "x");
let only_cache = HybridRepository::merge_search_results(
SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 },
SearchResult { items: vec![], total_record_count: 0 },
);
assert_eq!(only_cache.items.len(), 1);
assert_eq!(only_cache.items[0].id, "y");
}
}
+22
View File
@@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get albums the user has played, but not recently ("rediscover" / haven't
/// listened to in a while). Returns albums sorted by least-recently played
/// first, optionally restricted to a parent library.
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get resume movies
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
@@ -108,6 +117,19 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
/// Get Live TV channels (broadcast / IPTV) for browsing.
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
/// Get the root list of plugin "Channels" (Jellyfin Channels feature).
/// Drill-down into a channel reuses `get_items(channel_id, ...)`.
async fn get_channels(&self) -> Result<SearchResult, RepoError>;
/// Open a live stream (Live TV channel or live channel item) for playback.
///
/// Returns the server transcoding URL plus identifiers needed to manage the
/// stream. Required before a live channel can be played over HLS.
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError>;
/// Report playback start
///
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
+81 -30
View File
@@ -29,6 +29,7 @@ impl OfflineRepository {
id: item.id.clone(),
name: item.name,
item_type: item.item_type,
is_folder: item.is_folder,
server_id: item.server_id,
parent_id: item.parent_id,
library_id: item.library_id,
@@ -92,6 +93,7 @@ struct CachedItem {
id: String,
name: String,
item_type: String,
is_folder: bool,
server_id: String,
parent_id: Option<String>,
library_id: Option<String>,
@@ -143,6 +145,8 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
season_id: row.get(20)?,
season_name: row.get(21)?,
parent_index_number: row.get(22)?,
// Appended as the final column in every SELECT that maps through this fn.
is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
})
}
@@ -230,7 +234,7 @@ impl OfflineRepository {
let query = Query::with_params(
"INSERT OR REPLACE INTO items (
id, server_id, library_id, parent_id,
name, item_type, overview,
name, item_type, is_folder, overview,
genres, series_id, series_name,
season_id, season_name, index_number, parent_index_number,
album_id, album_name, album_artist, artists,
@@ -240,14 +244,14 @@ impl OfflineRepository {
synced_at
) VALUES (
?1, ?2, ?3, ?4,
?5, ?6, ?7,
?8, ?9, ?10,
?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18,
?19, ?20,
?21, ?22,
?23, ?24,
?25
?5, ?6, ?7, ?8,
?9, ?10, ?11,
?12, ?13, ?14, ?15,
?16, ?17, ?18, ?19,
?20, ?21,
?22, ?23,
?24, ?25,
?26
)",
vec![
QueryParam::String(item.id.clone()),
@@ -261,6 +265,7 @@ impl OfflineRepository {
},
QueryParam::String(item.name.clone()),
QueryParam::String(item.item_type.clone()),
QueryParam::Int(if item.is_folder { 1 } else { 0 }),
match &item.overview {
Some(o) => QueryParam::String(o.clone()),
None => QueryParam::Null,
@@ -491,7 +496,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number
i.parent_index_number, i.is_folder
FROM items i
INNER JOIN available_items ai ON i.id = ai.id
WHERE i.server_id = ? AND i.parent_id = ?{}
@@ -556,7 +561,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number
i.parent_index_number, i.is_folder
FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.id = ?",
@@ -601,7 +606,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number
i.parent_index_number, i.is_folder
FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND i.library_id = ?
@@ -639,7 +644,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
i.season_name, i.parent_index_number, i.is_folder
FROM items i
JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id
@@ -664,7 +669,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
i.season_name, i.parent_index_number, i.is_folder
FROM items i
JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id
@@ -752,7 +757,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
i.season_name, i.parent_index_number, i.is_folder
FROM ranked_plays rp
JOIN items i ON rp.display_id = i.id
INNER JOIN downloaded_items di ON i.id = di.id
@@ -778,6 +783,17 @@ impl MediaRepository for OfflineRepository {
Ok(items)
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
// "Rediscover" is a discovery feature over the full server library.
// Offline only holds downloaded items, so there is nothing meaningful
// to surface here; the hybrid repo serves this from the server instead.
Ok(Vec::new())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
@@ -789,7 +805,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
i.season_name, i.parent_index_number, i.is_folder
FROM items i
JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id
@@ -819,41 +835,57 @@ impl MediaRepository for OfflineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Extract unique genres from cached items
let mut genre_set = std::collections::HashSet::new();
// Derive genres from cached albums, tallying how many albums carry each
// so the frontend can rank by popularity. We scope to MusicAlbum (genres
// power the music landing) and read every matching row — NOT DISTINCT —
// so the per-genre counts are real. Genres are stored as a JSON array
// string per item.
let (sql, params) = if let Some(pid) = parent_id {
(
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL",
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![
QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()),
]
],
)
} else {
(
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())]
"SELECT genres FROM items WHERE server_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
)
};
let query = Query::with_params(sql, params);
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0))
let genres_rows: Vec<String> = self
.db_service
.query_many(query, |row| row.get(0))
.await
.map_err(|e| RepoError::Database { message: e })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec {
genre_set.insert(genre);
if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
}
}
}
let genres = genre_set
let genres = counts
.into_iter()
.map(|name| Genre { id: name.clone(), name })
.map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect();
Ok(genres)
@@ -907,7 +939,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
i.season_name, i.parent_index_number, i.is_folder
FROM items i
JOIN items_fts fts ON fts.rowid = i.rowid
INNER JOIN downloaded_items di ON i.id = di.id
@@ -953,6 +985,21 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV is inherently online-only.
Err(RepoError::Offline)
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Plugin channels are inherently online-only.
Err(RepoError::Offline)
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Live streams cannot be opened offline.
Err(RepoError::Offline)
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
// Cannot report to server while offline
Err(RepoError::Offline)
@@ -1044,6 +1091,7 @@ impl MediaRepository for OfflineRepository {
id: person_data.0,
name: person_data.1,
item_type: "Person".to_string(),
is_folder: false,
server_id: self.server_id.clone(),
parent_id: None,
library_id: None,
@@ -1104,7 +1152,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
i.season_name, i.parent_index_number, i.is_folder
FROM items i
JOIN item_people ip ON i.id = ip.item_id
INNER JOIN downloaded_items di ON i.id = di.id
@@ -1215,7 +1263,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
i.parent_index_number \
i.parent_index_number, i.is_folder \
FROM playlist_items pi \
JOIN items i ON pi.item_id = i.id \
WHERE pi.playlist_id = ? \
@@ -1253,6 +1301,7 @@ impl MediaRepository for OfflineRepository {
season_id: row.get(21)?,
season_name: row.get(22)?,
parent_index_number: row.get(23)?,
is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
};
Ok((entry_id.to_string(), cached))
})
@@ -1419,6 +1468,7 @@ mod tests {
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
name TEXT NOT NULL,
item_type TEXT NOT NULL,
is_folder INTEGER DEFAULT 0,
overview TEXT,
genres TEXT,
runtime_ticks INTEGER,
@@ -1491,6 +1541,7 @@ mod tests {
id: id.to_string(),
name: name.to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "test-server".to_string(),
parent_id: parent_id.map(|s| s.to_string()),
library_id: None,
+199 -9
View File
@@ -363,6 +363,8 @@ struct JellyfinItem {
name: String,
#[serde(rename = "Type")]
item_type: String,
#[serde(default)]
is_folder: bool,
parent_id: Option<String>,
overview: Option<String>,
genres: Option<Vec<String>>,
@@ -450,6 +452,7 @@ impl JellyfinItem {
id: self.id,
name: self.name,
item_type: self.item_type,
is_folder: self.is_folder,
server_id,
parent_id: self.parent_id,
library_id: None, // Not provided by Jellyfin API directly
@@ -568,10 +571,22 @@ impl MediaRepository for OnlineRepository {
if let Some(recursive) = opts.recursive {
endpoint.push_str(&format!("&Recursive={}", recursive));
}
if let Some(genres) = opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces/ampersands, so percent-encode each.
let encoded: Vec<String> = genres
.iter()
.map(|g| urlencoding::encode(g).into_owned())
.collect();
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
}
}
}
// Request image fields for list views (People only needed in get_item detail view)
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
// Request image fields for list views (People only needed in get_item
// detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -716,6 +731,7 @@ impl MediaRepository for OnlineRepository {
id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
is_folder: true,
server_id: first_track.server_id.clone(),
parent_id: None,
library_id: None,
@@ -759,6 +775,32 @@ impl MediaRepository for OnlineRepository {
Ok(final_result)
}
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Ask Jellyfin for played albums sorted by least-recently played first.
// Filters=IsPlayed keeps only albums the user has actually listened to,
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
let mut endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_val
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
@@ -775,7 +817,12 @@ impl MediaRepository for OnlineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let mut endpoint = format!("/Genres?UserId={}", self.user_id);
// Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
@@ -792,17 +839,41 @@ impl MediaRepository for OnlineRepository {
struct JellyfinGenre {
id: String,
name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
}
let response: GenresResponse = self.get_json(&endpoint).await?;
Ok(response
let genres: Vec<Genre> = response
.items
.into_iter()
.map(|g| Genre {
id: g.id,
name: g.name,
album_count: g.album_count.or(g.child_count),
})
.collect())
.collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
// see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(),
with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
);
Ok(genres)
}
async fn search(
@@ -811,19 +882,30 @@ impl MediaRepository for OnlineRepository {
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
// SearchTerm is arbitrary user input and must be percent-encoded so that
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
// search like "Star Wars" would otherwise produce a malformed URL).
let mut endpoint = format!(
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
self.user_id, query, limit
self.user_id,
urlencoding::encode(query),
limit
);
if let Some(opts) = options {
if let Some(types) = opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
let encoded_types = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect::<Vec<_>>()
.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
}
}
// Request image fields for list views (People only needed in get_item detail view)
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
// Request image fields for list views (plus Genres so cached items
// carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
@@ -1061,6 +1143,105 @@ impl MediaRepository for OnlineRepository {
Ok(url)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
let endpoint = format!(
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
self.user_id
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect())
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Root list of plugin "Channels". Drill-down into a channel folder reuses
// get_items(channel_id, ...).
let endpoint = format!("/Channels?UserId={}", self.user_id);
let response: ItemsResponse = self.get_json(&endpoint).await?;
let total = response.total_record_count;
let items = response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect();
Ok(SearchResult {
items,
total_record_count: total,
})
}
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
// server opens the live stream and returns a ready-to-play transcoding URL.
// We send a minimal request; the server applies its own defaults for live.
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamRequest {
user_id: String,
#[serde(rename = "AutoOpenLiveStream")]
auto_open_live_stream: bool,
is_playback: bool,
max_streaming_bitrate: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamResponse {
#[serde(default)]
media_sources: Vec<LiveMediaSource>,
play_session_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct LiveMediaSource {
id: String,
transcoding_url: Option<String>,
live_stream_id: Option<String>,
}
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
let request = OpenLiveStreamRequest {
user_id: self.user_id.clone(),
auto_open_live_stream: true,
is_playback: true,
max_streaming_bitrate: 20_000_000,
};
let response: OpenLiveStreamResponse =
self.post_json_response(&endpoint, &request).await?;
let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
// The transcoding URL is server-relative; make it absolute. If the server
// did not provide one (rare for live), fall back to the HLS master endpoint.
let stream_url = match source.transcoding_url {
Some(url) => format!("{}{}", self.server_url, url),
None => format!(
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
self.server_url,
item_id,
self.access_token,
source.id,
source.live_stream_id.clone().unwrap_or_default(),
),
};
Ok(LiveStreamInfo {
stream_url,
play_session_id: response.play_session_id,
live_stream_id: source.live_stream_id,
media_source_id: Some(source.id),
})
}
async fn report_playback_start(
&self,
item_id: &str,
@@ -1768,4 +1949,13 @@ mod tests {
assert_eq!(response.items[0].id, "item1");
assert_eq!(response.items[1].id, "item2");
}
#[test]
fn test_search_term_is_url_encoded() {
// A multi-word query (and one with a reserved character) must be
// percent-encoded before being placed in the SearchTerm query param,
// otherwise the request URL is malformed and search returns nothing.
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
}
}
+25
View File
@@ -104,6 +104,10 @@ pub struct MediaItem {
pub name: String,
#[serde(rename = "type")]
pub item_type: String,
/// Whether this item is a folder/container (vs a playable leaf). Used to
/// decide whether a channel item drills into a list or plays directly.
#[serde(default)]
pub is_folder: bool,
pub server_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
@@ -249,12 +253,30 @@ pub struct PlaybackInfo {
pub needs_transcoding: bool,
}
/// Live stream information returned from opening a Live TV / channel stream.
///
/// Unlike on-demand video, a live channel must be "opened" before it can be
/// streamed; the server returns a transcoding URL (already absolute) plus a
/// `live_stream_id` that can later be used to close the stream.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveStreamInfo {
pub stream_url: String,
pub play_session_id: Option<String>,
pub live_stream_id: Option<String>,
pub media_source_id: Option<String>,
}
/// Genre
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Genre {
pub id: String,
pub name: String,
/// Number of albums tagged with this genre, when the backend can supply it
/// (online only). Lets the frontend rank/pick genres without probing each
/// one. `None` when unknown (e.g. offline).
pub album_count: Option<u32>,
}
/// Image type
@@ -477,6 +499,7 @@ mod tests {
id: "1".to_string(),
name: "Test".to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(),
parent_id: None,
library_id: None,
@@ -616,6 +639,7 @@ mod tests {
id: "track1".to_string(),
name: "Test Track".to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(),
parent_id: None,
library_id: None,
@@ -675,6 +699,7 @@ mod tests {
id: "1".to_string(),
name: "Track".to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "s1".to_string(),
parent_id: None,
library_id: None,
+26
View File
@@ -21,6 +21,8 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("014_series_audio_preferences", MIGRATION_014),
("015_device_id", MIGRATION_015),
("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018),
];
/// Initial schema migration
@@ -667,3 +669,27 @@ const MIGRATION_016: &str = r#"
ALTER TABLE user_player_settings
ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
"#;
/// Migration to persist the resolved stream URL and target directory on each
/// download row. This lets the backend queue pump start a pending download by
/// itself (replaying the stored URL) once a concurrency slot frees up, instead
/// of relying on the frontend to re-issue every queued item.
const MIGRATION_017: &str = r#"
-- Resolved download source URL and on-disk target directory, captured when the
-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
-- URL simply won't be auto-started by the pump.
ALTER TABLE downloads ADD COLUMN stream_url TEXT;
ALTER TABLE downloads ADD COLUMN target_dir TEXT;
"#;
/// Migration to record whether a cached item is a folder/container vs a playable
/// leaf. Needed so channel items (which can be either) route to the player or to
/// a browse list correctly. Existing cached rows predate the column and have an
/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
/// causing the hybrid repository to re-fetch them from the server on next browse.
const MIGRATION_018: &str = r#"
ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
-- Force re-fetch of all cached items so is_folder is populated from the server.
UPDATE items SET synced_at = NULL;
"#;
+195 -8
View File
@@ -270,6 +270,31 @@ async remoteSessionSetVolume(sessionId: string, volume: number) : Promise<null>
async remoteSessionToggleMute(sessionId: string) : Promise<null> {
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId });
},
/**
* List current LMS sync groups.
*/
async lmsGetSyncGroups() : Promise<LmsSyncGroup[]> {
return await TAURI_INVOKE("lms_get_sync_groups");
},
/**
* Fuse LMS zones into a sync group. `master_mac` keeps playing and the
* `slave_macs` zones join it in sync.
*/
async lmsCreateSyncGroup(masterMac: string, slaveMacs: string[]) : Promise<null> {
return await TAURI_INVOKE("lms_create_sync_group", { masterMac, slaveMacs });
},
/**
* Remove a single LMS zone from its sync group (decouple one player).
*/
async lmsUnsyncPlayer(mac: string) : Promise<null> {
return await TAURI_INVOKE("lms_unsync_player", { mac });
},
/**
* Dissolve an entire LMS sync group, identified by its master's MAC.
*/
async lmsDissolveSyncGroup(masterMac: string) : Promise<null> {
return await TAURI_INVOKE("lms_dissolve_sync_group", { masterMac });
},
/**
* Set polling frequency hint based on UI state
*/
@@ -303,8 +328,8 @@ async playbackModeIsTransferring() : Promise<boolean> {
/**
* Transfer playback from local device to a remote Jellyfin session
*/
async playbackModeTransferToRemote(sessionId: string) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId });
async playbackModeTransferToRemote(sessionId: string, position: number | null) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId, position });
},
/**
* Get remote session status (for polling position/duration)
@@ -322,6 +347,17 @@ async playbackModeGetRemoteStatus() : Promise<RemoteSessionStatus> {
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks });
},
/**
* Set the transferring flag on the playback mode manager.
*
* Used by the frontend remote->local flow to mark the whole two-step sequence
* as a transfer, so `player_play_tracks` starts LOCAL playback instead of
* casting back to the remote session it's leaving. Always pair `true` with a
* later `false` (including on error) so the flag can't stick.
*/
async playbackModeSetTransferring(transferring: boolean) : Promise<null> {
return await TAURI_INVOKE("playback_mode_set_transferring", { transferring });
},
/**
* Initialize playback reporter (called after login)
*/
@@ -726,6 +762,31 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
async startDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("start_download", { downloadId, streamUrl, targetDir });
},
/**
* Enqueue a download with its resolved stream URL, then let the queue pump
* start it (or a higher-priority pending item) when a slot is free.
*
* Unlike [`start_download`], this never errors when the concurrency limit is
* reached: the URL is persisted on the row and the pump will pick it up once a
* slot frees. This is the path bulk operations (album/series/season) use so
* every queued item eventually downloads without the frontend re-issuing it.
*/
async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_download", { downloadId, streamUrl, targetDir });
},
/**
* Enqueue a batch of already-queued video downloads, resolving each one's
* transcode URL from the repository using the `quality_preset` stored on the
* row. Then let the pump start them subject to the concurrency limit.
*
* This is the bulk video path (series/season): `download_series`/
* `download_season` insert the rows, then this resolves URLs and enqueues them
* so they actually start. Resolving server-side avoids round-tripping every
* episode URL through the frontend.
*/
async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir });
},
/**
* Get download manager statistics
*/
@@ -1037,6 +1098,12 @@ async repositoryGetRecentlyPlayedAudio(handle: string, limit: number | null) : P
async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_resume_movies", { handle, limit });
},
/**
* Get albums the user hasn't listened to recently ("rediscover")
*/
async repositoryGetRediscoverAlbums(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_rediscover_albums", { handle, parentId, limit });
},
/**
* Get genres for a library
*/
@@ -1046,8 +1113,8 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
/**
* Search for items
*/
async repositorySearch(handle: string, query: string, options: SearchOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options });
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
},
/**
* Get playback info for an item
@@ -1067,6 +1134,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
},
/**
* Get Live TV channels (broadcast / IPTV) for browsing
*/
async repositoryGetLiveTvChannels(handle: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_live_tv_channels", { handle });
},
/**
* Get the root list of plugin "Channels"
*/
async repositoryGetChannels(handle: string) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_channels", { handle });
},
/**
* Open a live stream for a Live TV channel / live item
*/
async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStreamInfo> {
return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId });
},
/**
* Report playback start
*/
@@ -1245,6 +1330,11 @@ async convertPercentToVolume(percent: number) : Promise<number> {
/** user-defined events **/
export const events = __makeEvents__<{
playerStatusEvent: PlayerStatusEvent
}>({
playerStatusEvent: "player-status-event"
})
/** user-defined constants **/
@@ -1440,7 +1530,13 @@ export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStat
/**
* Genre
*/
export type Genre = { id: string; name: string }
export type Genre = { id: string; name: string;
/**
* Number of albums tagged with this genre, when the backend can supply it
* (online only). Lets the frontend rank/pick genres without probing each
* one. `None` when unknown (e.g. offline).
*/
albumCount: number | null }
/**
* Request to get an image URL (with caching)
*/
@@ -1461,10 +1557,30 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
* Library (media collection)
*/
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
/**
* Live stream information returned from opening a Live TV / channel stream.
*
* Unlike on-demand video, a live channel must be "opened" before it can be
* streamed; the server returns a transcoding URL (already absolute) plus a
* `live_stream_id` that can later be used to close the stream.
*/
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
/**
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
*
* Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
* follow it in lockstep.
*/
export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: string[]; slaveNames?: string[] }
/**
* Media item
*/
export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
export type MediaItem = { id: string; name: string; type: string;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/**
* Media session type tracking the high-level playback context
*/
@@ -1579,7 +1695,12 @@ export type PlayTracksContext = { type: "playlist"; playlistId: string; playlist
/**
* Request to play tracks by ID (backend fetches metadata)
*/
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext }
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext;
/**
* Position (seconds) to resume the starting track from. Used when taking
* over playback from a remote session so we don't restart from 0.
*/
startPosition?: number | null }
/**
* Playback information
*/
@@ -1760,6 +1881,67 @@ mergedIsPlaying: boolean;
* Volume from either local player or remote session (0-1 normalized)
*/
mergedVolume: number }
/**
* Events emitted by the player backend to the frontend via Tauri events.
*
* These are distinct from `PlayerEvent` in state.rs, which handles internal
* state machine transitions.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
/**
* Playback position updated (emitted periodically during playback)
*/
{ type: "position_update"; position: number; duration: number } |
/**
* Player state changed
*/
{ type: "state_changed"; state: string; media_id: string | null } |
/**
* Media has finished loading and is ready to play
*/
{ type: "media_loaded"; duration: number } |
/**
* Playback has ended naturally (reached end of media)
*/
{ type: "playback_ended" } |
/**
* Buffering state changed
*/
{ type: "buffering"; percent: number } |
/**
* An error occurred during playback
*/
{ type: "error"; message: string; recoverable: boolean } |
/**
* Volume changed
*/
{ type: "volume_changed"; volume: number; muted: boolean } |
/**
* Sleep timer state changed
*/
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Show next episode popup with countdown
*/
{ type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean } |
/**
* Countdown tick (emitted every second during autoplay countdown)
*/
{ type: "countdown_tick"; remaining_seconds: number } |
/**
* Queue changed (items added, removed, reordered, or playback mode changed)
*/
{ type: "queue_changed"; items: PlayerMediaItem[]; current_index: number | null; shuffle: boolean; repeat: RepeatMode; has_next: boolean; has_previous: boolean } |
/**
* Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
*/
{ type: "session_changed"; session: MediaSessionType } |
/**
* Remote sessions updated (for cast/remote control UI)
*/
{ type: "sessions_updated"; sessions: SessionInfo[] }
/**
* Result of creating a playlist
*
@@ -1776,7 +1958,12 @@ export type PlaylistEntry =
/**
* The underlying media item
*/
({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
({ id: string; name: string; type: string;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/
+1
View File
@@ -334,6 +334,7 @@ describe("RepositoryClient", () => {
includeItemTypes: ["Audio"],
limit: 100,
},
requestId: 0,
});
});
});
+25 -2
View File
@@ -11,6 +11,7 @@ import type {
GetItemsOptions,
SearchOptions,
PlaybackInfo,
LiveStreamInfo,
ImageType,
ImageOptions,
Genre,
@@ -109,12 +110,17 @@ export class RepositoryClient {
return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null);
}
/** Albums the user has played but not listened to recently ("rediscover"). */
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null);
}
async getGenres(parentId?: string): Promise<Genre[]> {
return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
}
async search(query: string, options?: SearchOptions): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null);
async search(query: string, options?: SearchOptions, requestId = 0): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId);
}
// ===== Playback Methods (all via Rust) =====
@@ -156,6 +162,23 @@ export class RepositoryClient {
);
}
// ===== Live TV / Channels =====
/** Browse Live TV channels (broadcast / IPTV). */
async getLiveTvChannels(): Promise<MediaItem[]> {
return commands.repositoryGetLiveTvChannels(this.ensureHandle());
}
/** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */
async getChannels(): Promise<SearchResult> {
return commands.repositoryGetChannels(this.ensureHandle());
}
/** Open a live stream for a Live TV channel / live item before HLS playback. */
async openLiveStream(itemId: string): Promise<LiveStreamInfo> {
return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId);
}
// ===== URL Construction Methods (sync, no server call) =====
/**
+4
View File
@@ -5,12 +5,14 @@
// and adds frontend-only unions/helpers that have no backend equivalent.
export type {
ArtistItem,
AuthResult,
Genre,
GetItemsOptions,
ImageOptions,
ImageType,
Library,
LiveStreamInfo,
MediaItem,
MediaSource,
MediaStream,
@@ -50,6 +52,7 @@ export type ItemType =
| "CollectionFolder"
| "Channel"
| "ChannelFolderItem"
| "TvChannel"
| "Person";
export type LibraryType =
@@ -62,6 +65,7 @@ export type LibraryType =
| "boxsets"
| "playlists"
| "channels"
| "livetv"
| "unknown";
export type PersonType =
@@ -91,15 +91,18 @@
// Get target directory for downloads
const targetDir = await commands.storageGetPath();
// Start each queued track download
// Enqueue each track with its resolved stream URL. The backend queue
// pump starts up to max_concurrent at a time and advances through the
// rest automatically as slots free up — so we never hit (and silently
// drop) the concurrency limit the way startDownload did.
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) {
await commands.startDownload(downloadIds[i], streamUrl, targetDir);
await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
}
} catch (e) {
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e);
}
}
@@ -0,0 +1,142 @@
<!-- TRACES: UR-007 | DR-007 -->
<script lang="ts">
/**
* A vertical A-Z index strip for long, alphabetically-sorted lists.
* Tapping or dragging a letter jumps to the first item that starts with it.
*
* The parent owns the actual scrolling: it passes `availableLetters`
* (which letters have items) and an `onJump(letter)` callback.
*/
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const HASH = "#"; // bucket for names starting with a digit/symbol
interface Props {
/** Uppercase letters (or "#") that currently have at least one item. */
availableLetters: Set<string>;
/** Called with the chosen letter when the user picks one. */
onJump: (letter: string) => void;
/**
* CSS length reserved at the bottom of the viewport for the bottom nav /
* mini-player bars. The strip stretches to fill the space between the top
* sticky offset and this gap, so it ends just above those bars.
*/
bottomGap?: string;
}
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let stripHeight = $state(0);
function measure() {
if (!container) return;
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
}
$effect(() => {
measure();
const scroller = container?.closest("main");
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
scroller?.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
function jumpTo(letter: string) {
if (!availableLetters.has(letter)) return;
activeLetter = letter;
onJump(letter);
}
// Allow dragging a finger/mouse down the strip to scrub through letters.
function letterAt(clientY: number): string | null {
const el = document.elementFromPoint(
// x is supplied by the caller via the bound container; we read from the
// element under the pointer instead to stay layout-agnostic.
lastClientX,
clientY
);
const letter = el?.getAttribute?.("data-letter");
return letter ?? null;
}
let lastClientX = $state(0);
let isScrubbing = $state(false);
function handlePointerDown(e: PointerEvent) {
isScrubbing = true;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter) jumpTo(letter);
}
function handlePointerMove(e: PointerEvent) {
if (!isScrubbing) return;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter && letter !== activeLetter) jumpTo(letter);
}
function handlePointerUp() {
isScrubbing = false;
}
</script>
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
<div
bind:this={container}
class="flex flex-col items-center justify-between select-none touch-none py-1"
style="height: {stripHeight}px"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="navigation"
aria-label="Jump to letter"
>
{#each letters as letter (letter)}
{@const enabled = availableLetters.has(letter)}
<button
type="button"
data-letter={letter}
disabled={!enabled}
onclick={() => jumpTo(letter)}
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
aria-label={`Jump to ${letter}`}
>
{letter}
</button>
{/each}
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { ArtistItem } from "$lib/api/types";
/**
* Renders a comma-separated list of artist names. When the artists carry
* ids (artistItems) each name links to that artist's detail page; otherwise
* it falls back to plain text from the names-only `artists` array.
*
* Consolidates the artist-linking markup used on the player and detail
* screens so every surface behaves the same way.
*/
interface Props {
artistItems?: ArtistItem[] | null;
artists?: string[] | null;
/** Tailwind classes for the link colour/state. */
linkClass?: string;
/** Tailwind classes for the non-linkable fallback text. */
textClass?: string;
/** Called before navigating (e.g. to close a full-screen player). */
onNavigate?: () => void;
}
let {
artistItems = null,
artists = null,
linkClass = "text-[var(--color-jellyfin)] hover:underline",
textClass = "text-gray-400",
onNavigate,
}: Props = $props();
const linkable = $derived(
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
);
function handleClick(artistId: string, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
onNavigate?.();
goto(`/library/${artistId}`);
}
</script>
{#if linkable.length > 0}
<span class="inline-flex flex-wrap items-baseline gap-1">
{#each linkable as artist, i (artist.id)}
<button onclick={(e) => handleClick(artist.id, e)} class={linkClass}>
{artist.name}
</button>{#if i < linkable.length - 1}<span class={textClass}>,</span>{/if}
{/each}
</span>
{:else if artists && artists.length > 0}
<span class={textClass}>{artists.join(", ")}</span>
{/if}
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
import ArtistLinks from "./ArtistLinks.svelte";
const goto = vi.fn();
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args),
}));
describe("ArtistLinks", () => {
beforeEach(() => goto.mockClear());
it("renders a clickable link per artist that navigates to its detail page", async () => {
render(ArtistLinks, {
artistItems: [
{ id: "a1", name: "Radiohead" },
{ id: "a2", name: "Thom Yorke" },
],
});
const link = screen.getByRole("button", { name: "Radiohead" });
await fireEvent.click(link);
expect(goto).toHaveBeenCalledWith("/library/a1");
expect(screen.getByRole("button", { name: "Thom Yorke" })).toBeTruthy();
});
it("falls back to plain text when only names (no ids) are available", () => {
render(ArtistLinks, { artists: ["Unknown Artist"] });
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("Unknown Artist")).toBeTruthy();
});
it("ignores artistItems with blank ids and uses the names fallback", () => {
render(ArtistLinks, {
artistItems: [{ id: " ", name: "No Id Artist" }],
artists: ["No Id Artist"],
});
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("No Id Artist")).toBeTruthy();
});
it("calls onNavigate before navigating (e.g. to close a player)", async () => {
const onNavigate = vi.fn();
render(ArtistLinks, {
artistItems: [{ id: "a1", name: "Radiohead" }],
onNavigate,
});
await fireEvent.click(screen.getByRole("button", { name: "Radiohead" }));
expect(onNavigate).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith("/library/a1");
});
});
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte";
@@ -125,7 +126,7 @@
selectedGenre = null;
genreItems = [];
} else {
goto(config.backPath);
navigateBack(config.backPath);
}
}
@@ -2,8 +2,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isAndroid } from "$lib/stores/appState";
import SearchBar from "$lib/components/common/SearchBar.svelte";
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
@@ -13,6 +16,8 @@
import type { MediaItem, Library, ItemType } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte";
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
import { excludePodcasts } from "$lib/utils/podcastFilter";
/**
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
@@ -41,6 +46,7 @@
let items = $state<MediaItem[]>([]);
let loading = $state(true);
let gridWrapper = $state<HTMLDivElement | null>(null);
let searchQuery = $state("");
let debouncedSearchQuery = $state("");
let sortBy = $state<string>("");
@@ -74,12 +80,13 @@
const repo = auth.getRepository();
// Use backend search if search query is provided, otherwise use getItems with sort
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
if (debouncedSearchQuery.trim()) {
const result = await repo.search(debouncedSearchQuery, {
includeItemTypes: [config.itemType],
limit: 10000,
});
items = result.items;
items = excludePodcasts(result.items);
} else {
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: [config.itemType],
@@ -88,7 +95,7 @@
recursive: true,
limit: 10000,
});
items = result.items;
items = excludePodcasts(result.items);
}
} catch (e) {
console.error(`Failed to load ${config.itemType}:`, e);
@@ -124,7 +131,7 @@
}
function goBack() {
goto(config.backPath);
navigateBack(config.backPath);
}
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
@@ -142,6 +149,54 @@
goto(`/library/${track.id}`);
}
}
// ===== A-Z jump bar =====
// Bucket a name to its index letter: A-Z, or "#" for digits/symbols/empty.
function letterFor(name: string): string {
const first = (name ?? "").trim().charAt(0).toUpperCase();
return first >= "A" && first <= "Z" ? first : "#";
}
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
const isAlphaSorted = $derived(sortBy === "SortName");
const showAlphaBar = $derived(
isAlphaSorted &&
!loading &&
!debouncedSearchQuery.trim() &&
items.length > 30
);
const availableLetters = $derived.by(() => {
const set = new Set<string>();
if (showAlphaBar) {
for (const item of items) set.add(letterFor(item.name));
}
return set;
});
// First item index for each letter, honouring current ascending/descending order.
const firstIndexForLetter = $derived.by(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
const letter = letterFor(item.name);
if (!map.has(letter)) map.set(letter, index);
});
return map;
});
function jumpToLetter(letter: string) {
const index = firstIndexForLetter.get(letter);
if (index === undefined || !gridWrapper) return;
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived(
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
);
</script>
<div class="space-y-6">
@@ -192,10 +247,19 @@
<p>No {config.title.toLowerCase()} found</p>
</div>
{:else}
<div class="flex gap-2">
<div bind:this={gridWrapper} class="flex-1 min-w-0">
{#if config.displayComponent === "grid"}
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
{:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if}
</div>
{#if showAlphaBar}
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
</div>
{/if}
</div>
{/if}
</div>
@@ -69,12 +69,14 @@
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each items as item (item.id)}
{#each items as item, index (item.id)}
<div data-grid-index={index}>
<MediaCard
{item}
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
</div>
{/each}
</div>
{/if}
@@ -68,6 +68,7 @@
<button
type="button"
data-grid-index={index}
onclick={() => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
>
@@ -71,6 +71,13 @@
// Pin the season item
await downloads.pinItem(seasonId);
// Resolve each episode's transcode URL (server-side) and enqueue. The
// backend pump then starts up to max_concurrent and advances through the
// rest as slots free up.
const handle = auth.getRepository().getHandle();
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
console.log(" Episodes enqueued; backend pump will start them");
} catch (error) {
console.error("Failed to start season download:", error);
} finally {
@@ -64,9 +64,12 @@
// Pin the series item
await downloads.pinItem(seriesId);
// Start downloads (the backend will handle queuing)
// For now, we'll rely on a download manager to pick them up
// TODO: Implement batch download start
// Resolve each episode's transcode URL (server-side) and enqueue. The
// backend pump then starts up to max_concurrent and advances through the
// rest as slots free up.
const handle = auth.getRepository().getHandle();
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
console.log(" Episodes enqueued; backend pump will start them");
} catch (error) {
console.error("Failed to start series download:", error);
} finally {
+1 -1
View File
@@ -204,7 +204,7 @@
<!-- Track Rows -->
<div class="space-y-1">
{#each tracks as track, index (track.id)}
<div class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
<div data-grid-index={index} class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
<!-- Desktop View -->
<button
onclick={() => handleTrackClick(track, index)}
@@ -28,6 +28,7 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(),
addToQueue: vi.fn(),
},
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
}));
vi.mock("./DownloadButton.svelte", () => ({
+37 -15
View File
@@ -30,9 +30,10 @@
onEnded?: () => void; // Called when video playback ends naturally
onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
}
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
@@ -43,6 +44,18 @@
let videoElement: HTMLVideoElement | null = $state(null);
let isPlaying = $state(false);
let currentTime = $state(0);
// Guards against onEnded() firing more than once per loaded stream. For
// transcoded HLS, both the native `ended` event and the "fatal network error
// near end of stream" recovery path can fire for the same playback, which
// would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
// sleep-timer episode counter twice). Reset when the stream URL changes.
let endedFired = $state(false);
function notifyEnded() {
if (endedFired) return;
endedFired = true;
onEnded?.();
}
let isFullscreen = $state(false);
let showControls = $state(true);
let showSleepTimerModal = $state(false);
@@ -188,6 +201,7 @@
seekOffset = 0;
isMediaReady = false; // Reset to loading state when stream URL changes
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
endedFired = false; // New stream loaded - allow onEnded to fire again
}
});
@@ -285,9 +299,7 @@
if (isNearEnd) {
// Near end of stream - treat as natural end, don't restart
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
if (onEnded) {
onEnded();
}
notifyEnded();
} else if (hlsFatalRecoveryAttempts <= 3) {
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
hls!.startLoad();
@@ -477,12 +489,15 @@
// Load series audio preference (for TV shows)
await loadSeriesAudioPreference();
// Report progress every 10 seconds while playing
// Report progress every 10 seconds while playing. Live streams have no
// meaningful position to report, so skip progress reporting entirely.
if (!isLive) {
progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false, reportMediaId);
}
}, 10000);
}
// Debug logging every second
debugLogInterval = setInterval(() => {
@@ -544,8 +559,8 @@
}
}
// Report stop when component is destroyed
if (onReportStop && currentTime > 0) {
// Report stop when component is destroyed (skip for live - no resume tracking)
if (!isLive && onReportStop && currentTime > 0) {
onReportStop(currentTime, reportMediaId);
}
});
@@ -738,8 +753,8 @@
function handlePlay() {
isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates
// Report playback start on first play
if (!hasReportedStart && onReportStart) {
// Report playback start on first play (skip for live - no resume tracking)
if (!isLive && !hasReportedStart && onReportStart) {
onReportStart(currentTime, reportMediaId);
hasReportedStart = true;
}
@@ -757,14 +772,12 @@
function handleEnded() {
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when ended
// Report stop when video ends
if (onReportStop) {
// Report stop when video ends (skip for live - no resume tracking)
if (!isLive && onReportStop) {
onReportStop(currentTime, reportMediaId);
}
// Notify parent that video has ended (for next episode popup)
if (onEnded) {
onEnded();
}
notifyEnded();
}
async function togglePlayPause() {
@@ -1404,7 +1417,15 @@
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
</div>
<!-- Progress bar -->
<!-- Progress bar (hidden for live streams - no fixed timeline) -->
{#if isLive}
<div class="flex items-center gap-2 mb-2">
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
LIVE
</span>
</div>
{:else}
<div class="flex items-center gap-2 mb-2">
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
<input
@@ -1424,6 +1445,7 @@
/>
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
</div>
{/if}
<!-- Control buttons -->
<div class="flex items-center justify-between">
@@ -1,7 +1,10 @@
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
<script lang="ts">
import { get } from "svelte/store";
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
import { playbackPosition } from "$lib/stores/player";
import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync";
import type { Session } from "$lib/api/types";
interface Props {
@@ -12,10 +15,24 @@
let { isOpen = false, onClose, onSelectSession }: Props = $props();
// MACs currently part of any sync group, for rendering toggle state.
const groupedMacs = $derived(new Set(($lmsSync.groups ?? []).flatMap(
(g) => [g.masterMac, ...(g.slaveMacs ?? [])]
)));
// The sync master is the LMS zone we're currently controlling. Zones can only
// be fused once we have a master to fuse them into.
const masterMac = $derived(isLmsSession($selectedSession) ? macForSession($selectedSession) : null);
function isZoneFused(session: Session): boolean {
const mac = macForSession(session);
return mac !== null && groupedMacs.has(mac);
}
async function handleSessionSelect(session: Session) {
try {
// Transfer playback to remote session
await playbackMode.transferToRemote(session.id);
// Transfer playback to remote session, resuming at our current position
await playbackMode.transferToRemote(session.id, get(playbackPosition));
if (onSelectSession) {
onSelectSession(session);
@@ -29,6 +46,32 @@
}
}
// Toggle an LMS zone in/out of the current master's sync group. If there's no
// master yet (we're not controlling an LMS zone), the click falls back to a
// normal transfer so the user can start playing on a zone first.
async function handleLmsZoneToggle(session: Session) {
const zoneMac = macForSession(session);
if (!zoneMac) return;
if (!masterMac) {
// No master yet — start playback on this zone; it becomes the master.
await handleSessionSelect(session);
await lmsSync.refresh();
return;
}
try {
if (isZoneFused(session)) {
await lmsSync.decoupleZone(zoneMac);
} else {
await lmsSync.fuseZone(masterMac, zoneMac);
}
} catch (error) {
console.error("Failed to toggle LMS zone:", error);
// Error is surfaced via the lmsSync store
}
}
async function handleTransferToLocal() {
try {
await playbackMode.transferToLocal();
@@ -78,6 +121,7 @@
$effect(() => {
if (isOpen) {
sessions.refresh();
lmsSync.refresh();
}
});
</script>
@@ -165,14 +209,24 @@
<!-- Available sessions -->
{#each $controllableSessions as session (session.id)}
{#if session.id !== $selectedSession?.id}
{@const lmsZone = isLmsSession(session)}
{@const fused = lmsZone && isZoneFused(session)}
<button
onclick={() => handleSessionSelect(session)}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left"
onclick={() => (lmsZone ? handleLmsZoneToggle(session) : handleSessionSelect(session))}
disabled={$lmsSync.isBusy}
aria-pressed={lmsZone ? fused : undefined}
class="w-full p-4 rounded-lg border transition-all text-left disabled:opacity-60
{fused
? 'border-[var(--color-jellyfin)] bg-[var(--color-jellyfin)]/10'
: 'border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5'}"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
{#if getSessionIcon(session.client) === "tv"}
{#if lmsZone}
<!-- speaker icon for LMS zones -->
<path d="M12 2a1 1 0 011 1v18a1 1 0 01-1 1H6a2 2 0 01-2-2V4a2 2 0 012-2h6zm0 2H6v16h6V4zm-3 9a2 2 0 110 4 2 2 0 010-4zm0-2a4 4 0 100 8 4 4 0 000-8zm0-4a1 1 0 110 2 1 1 0 010-2zm9 0h2v2h-2V7zm0 4h2v2h-2v-2z" />
{:else if getSessionIcon(session.client) === "tv"}
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
{:else if getSessionIcon(session.client) === "web"}
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" />
@@ -185,14 +239,33 @@
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-white truncate">{session.deviceName}</h3>
<p class="text-sm text-gray-400 truncate">{session.client}</p>
<p class="text-sm text-gray-400 truncate">
{#if lmsZone}
{fused ? "In sync group — tap to remove" : masterMac ? "Tap to add to group" : "Speaker zone"}
{:else}
{session.client}
{/if}
</p>
{#if session.nowPlayingItem}
<p class="text-xs text-gray-500 truncate mt-1">
Playing: {session.nowPlayingItem.name}
</p>
{/if}
</div>
{#if session.playState}
{#if lmsZone}
<!-- Fuse toggle indicator -->
<div class="flex-shrink-0">
{#if fused}
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
{:else}
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke-width="2" />
</svg>
{/if}
</div>
{:else if session.playState}
<div class="flex-shrink-0">
{#if session.playState.isPaused}
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
@@ -293,6 +366,27 @@
</div>
{/if}
<!-- LMS sync error -->
{#if $lmsSync.error}
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
<span class="text-sm">{$lmsSync.error}</span>
</div>
<button
onclick={() => lmsSync.clearError()}
class="text-white hover:text-gray-200 transition-colors"
aria-label="Dismiss error"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
<!-- Error Display -->
{#if $transferError}
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
+21 -60
View File
@@ -8,9 +8,9 @@
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { player, playbackPosition } from "$lib/stores/player";
import { type UnlistenFn } from "@tauri-apps/api/event";
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
import { player, playbackPosition, currentMedia } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode";
import { sleepTimer } from "$lib/stores/sleepTimer";
@@ -20,33 +20,9 @@ import { preloadUpcomingTracks } from "$lib/services/preload";
import type { MediaItem } from "$lib/api/types";
import { get } from "svelte/store";
/**
* Event types emitted by the player backend.
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
| { type: "position_update"; position: number; duration: number }
| { type: "state_changed"; state: string; media_id: string | null }
| { type: "media_loaded"; duration: number }
| { type: "playback_ended" }
| { type: "buffering"; percent: number }
| { type: "error"; message: string; recoverable: boolean }
| { type: "volume_changed"; volume: number; muted: boolean }
| { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number }
| { type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean }
| { type: "countdown_tick"; remaining_seconds: number };
// Sleep timer mode type
export type SleepTimerMode =
| { kind: "off" }
| { kind: "time"; endTime: number }
| { kind: "endOfTrack" }
| { kind: "episodes"; remaining: number };
/** Event name for player status events from backend */
const PLAYER_EVENT_NAME = "player-event";
// PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
// imported from $lib/api/bindings — they are the authoritative shapes emitted
// by src-tauri/src/player/events.rs.
let unlistenFn: UnlistenFn | null = null;
let isInitialized = false;
@@ -62,12 +38,9 @@ export async function initPlayerEvents(): Promise<void> {
}
try {
unlistenFn = await listen<PlayerStatusEvent>(
PLAYER_EVENT_NAME,
(event) => {
unlistenFn = await events.playerStatusEvent.listen((event) => {
handlePlayerEvent(event.payload);
}
);
});
isInitialized = true;
console.log("Player event listener initialized");
} catch (e) {
@@ -152,6 +125,10 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
case "countdown_tick":
handleCountdownTick(event.remaining_seconds);
break;
// queue_changed is handled by the queue store's own listener
// ($lib/stores/queue), which is the single source of truth for
// shuffle/repeat/next/previous. No action needed here.
}
}
@@ -186,9 +163,16 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
}
if (state === "playing" && currentItem) {
// Use 0 for position/duration - will be updated by position_update events
// Preserve the current position when the same track is already loaded
// (e.g. resuming from pause, or a spurious PlaybackRestart). Only reset
// to 0 when switching to a different track. position_update events keep
// it fresh either way, but resetting unconditionally caused the time to
// flash to 0:00 on pause/resume.
const previous = get(currentMedia);
const isSameTrack = previous?.id === currentItem.id;
const startPosition = isSameTrack ? get(playbackPosition) : 0;
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
player.setPlaying(currentItem, 0, initialDuration);
player.setPlaying(currentItem, startPosition, initialDuration);
// Trigger preloading of upcoming tracks in the background
preloadUpcomingTracks().catch((e) => {
@@ -204,8 +188,6 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
player.setLoading(currentItem);
}
// Update queue status on state change
await updateQueueStatus();
break;
case "idle":
@@ -218,31 +200,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
playbackMode.setMode("idle");
}
// Update queue status on state change
await updateQueueStatus();
break;
}
}
/**
* Update queue status from backend.
* Called on state changes instead of polling.
*/
async function updateQueueStatus(): Promise<void> {
try {
const queueStatus = await commands.playerGetQueue();
// Import appState stores dynamically to avoid circular imports
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
hasNext.set(queueStatus.hasNext);
hasPrevious.set(queueStatus.hasPrevious);
shuffle.set(queueStatus.shuffle);
repeat.set(queueStatus.repeat as "off" | "all" | "one");
} catch (e) {
console.error("[playerEvents] Failed to update queue status:", e);
}
}
/**
* Handle media loaded event.
*/
+2 -4
View File
@@ -6,10 +6,8 @@ import { writable } from 'svelte/store';
export const isInitialized = writable(false);
export const pendingSyncCount = writable(0);
export const isAndroid = writable(false);
export const shuffle = writable(false);
export const repeat = writable<'off' | 'all' | 'one'>('off');
export const hasNext = writable(false);
export const hasPrevious = writable(false);
// Shuffle/repeat/next/previous state now lives in the event-driven queue store
// ($lib/stores/queue), the single source of truth.
export const showSleepTimerModal = writable(false);
// Library-specific state
+94 -8
View File
@@ -2,9 +2,19 @@
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import { auth } from "./auth";
/**
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
* Carries the merged cache+server results for a given search request.
*/
interface SearchUpdateEvent {
requestId: number;
result: SearchResult;
}
export type ViewMode = "grid" | "list";
interface LibraryState {
@@ -47,8 +57,23 @@ function createLibraryStore() {
const { subscribe, set, update } = writable<LibraryState>(initialState);
// Test log to confirm cache logging is active
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
// Monotonic id identifying the most recent search request. Each new search
// bumps it; the deferred `search-event` (carrying merged cache+server
// results) is only applied when its requestId still matches the latest one,
// so out-of-order / superseded results never clobber fresher ones.
let searchRequestId = 0;
let unlistenSearch: UnlistenFn | null = null;
// Lazily subscribe to backend search updates the first time we search.
async function ensureSearchListener() {
if (unlistenSearch) return;
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
const { requestId, result } = event.payload;
// Ignore results from a query the user has already moved on from.
if (requestId !== searchRequestId) return;
update((s) => ({ ...s, searchResults: result.items }));
});
}
async function loadLibraries() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -127,6 +152,47 @@ function createLibraryStore() {
}
}
// Load Live TV channels (broadcast / IPTV) into the items list. Live TV uses a
// dedicated Jellyfin endpoint rather than the generic /Items browse.
async function loadLiveTvChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const channels = await repo.getLiveTvChannels();
update((s) => ({
...s,
items: channels,
totalItems: channels.length,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return channels;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load Live TV channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
// Load the root list of plugin "Channels" into the items list.
async function loadChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const result = await repo.getChannels();
update((s) => ({
...s,
items: result.items,
totalItems: result.totalRecordCount,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
async function loadItem(itemId: string) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -157,11 +223,17 @@ function createLibraryStore() {
}
async function search(query: string) {
// Bump the request id for every call (including clears) so any in-flight
// backend update for a previous query is ignored when it arrives.
const requestId = ++searchRequestId;
if (!query.trim()) {
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
return;
}
await ensureSearchListener();
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
try {
@@ -172,16 +244,21 @@ function createLibraryStore() {
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
);
// Phase 1: the command resolves with instant local-cache results. The
// merged (cache + server) union arrives later via the `search-event`
// listener above, tagged with this same requestId.
const result = await Promise.race([
repo.search(query, { limit: 10000 }),
repo.search(query, { limit: 10000 }, requestId),
timeoutPromise
]);
update((s) => ({
...s,
searchResults: result.items,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
// Only apply if this is still the active query (a newer search may have
// started while we awaited).
if (requestId === searchRequestId) {
update((s) => ({ ...s, searchResults: result.items }));
}
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1) }));
return result;
} catch (error) {
@@ -196,6 +273,8 @@ function createLibraryStore() {
}
function clearSearch() {
// Invalidate any in-flight backend search update.
searchRequestId++;
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
}
@@ -247,6 +326,11 @@ function createLibraryStore() {
}
function reset() {
searchRequestId++;
if (unlistenSearch) {
unlistenSearch();
unlistenSearch = null;
}
set(initialState);
}
@@ -254,6 +338,8 @@ function createLibraryStore() {
subscribe,
loadLibraries,
loadItems,
loadLiveTvChannels,
loadChannels,
loadItem,
search,
setCurrentLibrary,
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
import type { Session } from "$lib/api/types";
const mockInvoke = vi.fn();
vi.mock("$lib/api/bindings", () => ({
commands: {
lmsGetSyncGroups: () => mockInvoke("lms_get_sync_groups"),
lmsCreateSyncGroup: (masterMac: string, slaveMacs: string[]) =>
mockInvoke("lms_create_sync_group", { masterMac, slaveMacs }),
lmsUnsyncPlayer: (mac: string) => mockInvoke("lms_unsync_player", { mac }),
lmsDissolveSyncGroup: (masterMac: string) =>
mockInvoke("lms_dissolve_sync_group", { masterMac }),
},
}));
import { lmsSync, isLmsSession, macForSession } from "./lmsSync";
function session(deviceId: string | null): Session {
return { id: "s", deviceId } as unknown as Session;
}
describe("LMS session detection", () => {
it("detects LMS zones by the lms- device-id prefix", () => {
expect(isLmsSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe(true);
expect(isLmsSession(session("chromecast-123"))).toBe(false);
expect(isLmsSession(session(null))).toBe(false);
expect(isLmsSession(null)).toBe(false);
});
it("recovers the MAC from an LMS session, null otherwise", () => {
expect(macForSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe("aa:bb:cc:dd:ee:ff");
expect(macForSession(session("web-xyz"))).toBeNull();
expect(macForSession(null)).toBeNull();
});
});
describe("lmsSync store", () => {
beforeEach(() => {
mockInvoke.mockReset();
lmsSync.reset();
});
it("fusing preserves existing slaves and adds the new zone", async () => {
// Initial group: master M with slave A.
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
// create returns void; refresh after returns the updated group.
mockInvoke.mockResolvedValueOnce(undefined);
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]);
await lmsSync.fuseZone("M", "B");
expect(mockInvoke).toHaveBeenCalledWith("lms_create_sync_group", {
masterMac: "M",
slaveMacs: ["A", "B"],
});
expect(get(lmsSync).groups[0].slaveMacs).toEqual(["A", "B"]);
});
it("decoupling a slave unsyncs just that player", async () => {
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
mockInvoke.mockResolvedValueOnce(undefined); // unsync
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]);
await lmsSync.decoupleZone("A");
expect(mockInvoke).toHaveBeenCalledWith("lms_unsync_player", { mac: "A" });
});
it("decoupling the master dissolves the whole group", async () => {
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
await lmsSync.refresh();
mockInvoke.mockResolvedValueOnce(undefined); // dissolve
mockInvoke.mockResolvedValueOnce([]);
await lmsSync.decoupleZone("M");
expect(mockInvoke).toHaveBeenCalledWith("lms_dissolve_sync_group", { masterMac: "M" });
});
it("treats a missing plugin (refresh error) as no groups, not fatal", async () => {
mockInvoke.mockRejectedValueOnce(new Error("404"));
await lmsSync.refresh();
expect(get(lmsSync).groups).toEqual([]);
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* LMS multi-room sync ("fuse zones") store.
*
* The JellyLMS plugin registers each LMS player as a Jellyfin session whose
* device id is `lms-{MacAddress}`. We use that prefix both to detect which cast
* targets are fuseable LMS zones and to recover the MAC the SyncGroups API needs.
*
* Fusing model (per product decision): the currently-controlled zone is the sync
* master; other selected zones join it. Toggling an already-grouped zone off
* decouples just that zone; toggling the master off dissolves the group.
*
* TRACES: UR-010 | JA-021, JA-025 | DR-037
*/
import { writable, get } from "svelte/store";
import { commands } from "$lib/api/bindings";
import type { Session } from "$lib/api/types";
import type { LmsSyncGroup } from "$lib/api/bindings";
const LMS_DEVICE_PREFIX = "lms-";
/** Is this cast target an LMS zone (and therefore fuseable)? */
export function isLmsSession(session: Session | null | undefined): boolean {
return !!session?.deviceId?.startsWith(LMS_DEVICE_PREFIX);
}
/** Recover the LMS player MAC from a session, or null if it isn't an LMS zone. */
export function macForSession(session: Session | null | undefined): string | null {
const deviceId = session?.deviceId;
if (!deviceId?.startsWith(LMS_DEVICE_PREFIX)) return null;
return deviceId.slice(LMS_DEVICE_PREFIX.length);
}
interface LmsSyncState {
groups: LmsSyncGroup[];
isBusy: boolean;
error: string | null;
}
function createLmsSyncStore() {
const { subscribe, update, set } = writable<LmsSyncState>({
groups: [],
isBusy: false,
error: null,
});
/** Refresh the list of current sync groups from the server. */
async function refresh(): Promise<void> {
try {
const groups = await commands.lmsGetSyncGroups();
update((s) => ({ ...s, groups, error: null }));
} catch (error) {
// The plugin may not be installed; treat as "no groups" rather than fatal.
console.warn("[LmsSync] Failed to load sync groups:", error);
update((s) => ({ ...s, groups: [] }));
}
}
/** All MACs that are part of any sync group (master or slave). */
function groupedMacs(): Set<string> {
const macs = new Set<string>();
for (const g of get({ subscribe }).groups) {
macs.add(g.masterMac);
for (const slave of g.slaveMacs ?? []) macs.add(slave);
}
return macs;
}
/**
* Add an LMS zone to the master's sync group (fuse it in).
* Preserves any zones already grouped with the master.
*/
async function fuseZone(masterMac: string, zoneMac: string): Promise<void> {
if (masterMac === zoneMac) return;
update((s) => ({ ...s, isBusy: true, error: null }));
try {
const existing = get({ subscribe }).groups.find((g) => g.masterMac === masterMac);
const slaves = new Set(existing?.slaveMacs ?? []);
slaves.add(zoneMac);
await commands.lmsCreateSyncGroup(masterMac, [...slaves]);
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fuse zone";
update((s) => ({ ...s, error: message }));
throw error;
} finally {
update((s) => ({ ...s, isBusy: false }));
}
}
/**
* Remove a single zone from its group. If the zone is a group's master, the
* whole group is dissolved (a group can't outlive its master).
*/
async function decoupleZone(zoneMac: string): Promise<void> {
update((s) => ({ ...s, isBusy: true, error: null }));
try {
const asMaster = get({ subscribe }).groups.find((g) => g.masterMac === zoneMac);
if (asMaster) {
await commands.lmsDissolveSyncGroup(zoneMac);
} else {
await commands.lmsUnsyncPlayer(zoneMac);
}
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to decouple zone";
update((s) => ({ ...s, error: message }));
throw error;
} finally {
update((s) => ({ ...s, isBusy: false }));
}
}
function clearError(): void {
update((s) => ({ ...s, error: null }));
}
function reset(): void {
set({ groups: [], isBusy: false, error: null });
}
return {
subscribe,
refresh,
groupedMacs,
fuseZone,
decoupleZone,
clearError,
reset,
};
}
export const lmsSync = createLmsSyncStore();
+161
View File
@@ -0,0 +1,161 @@
// Movies library landing page data store.
// Powers the focused movies landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
/** A single "by genre" row: the genre name plus the movies in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface MoviesState {
// Movies the user can resume (continue watching).
continueWatching: MediaItem[];
// Recently added movies in the library.
recentlyAdded: MediaItem[];
// One slider per genre (top genres by movie count).
genreRows: GenreRow[];
// Mix used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
const MAX_GENRES_PROBED = 20;
function createMoviesStore() {
const initialState: MoviesState = {
continueWatching: [],
recentlyAdded: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<MoviesState>(initialState);
/**
* Build the hero rotation. Prefer in-progress movies (most personal), then
* fall back to recently added. De-duplicates by id and prefers items that
* carry backdrop/primary artwork for a good banner.
*/
function buildHero(continueWatching: MediaItem[], recentlyAdded: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [resume, latest] = await Promise.all([
repo.getResumeMovies(SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
]);
const heroItems = buildHero(resume, latest);
update(s => ({
...s,
continueWatching: resume,
recentlyAdded: latest,
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load movie sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load movie sections:", error);
}
}
/**
* Build one slider per genre, showing the top-rated movies in each. We probe
* a bounded set of genres in parallel, drop empty ones, then keep the genres
* with the most movies (so niche/near-empty genres don't crowd the page).
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
const probed = genres.slice(0, MAX_GENRES_PROBED);
const rows = await Promise.all(
probed.map(async (genre): Promise<GenreRow> => {
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["Movie"],
genres: [genre.name],
sortBy: "CommunityRating",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
});
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
})
);
const genreRows = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length)
.slice(0, MAX_GENRE_ROWS);
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load movie genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const movies = createMoviesStore();
export const moviesHeroItems = derived(movies, $m => $m.heroItems);
export const isMoviesLoading = derived(movies, $m => $m.isLoading);
+235
View File
@@ -0,0 +1,235 @@
// Music library landing page data store.
// Powers the focused music landing: hero + horizontal sliders.
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem, Genre } from "$lib/api/types";
import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter";
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
/** A single "by genre" row: the genre name plus the albums in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface MusicState {
// Albums grouped from recently played tracks (backend handles grouping).
recentlyPlayed: MediaItem[];
// Most recently added albums in the music library.
newlyAdded: MediaItem[];
// User playlists.
playlists: MediaItem[];
// Albums the user has played but hasn't returned to in a while.
rediscover: MediaItem[];
// One slider per genre (top genres by album count).
genreRows: GenreRow[];
// Mix of recently played + rediscover, used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
// Probe a wide pool so the diverse-selection step has genres from across the
// whole (alphabetical) list to choose between, not just the first handful.
const MAX_GENRES_PROBED = 40;
function createMusicStore() {
const initialState: MusicState = {
recentlyPlayed: [],
newlyAdded: [],
playlists: [],
rediscover: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<MusicState>(initialState);
/**
* Build the hero rotation from a mix of recently played and rediscover
* albums, interleaved so the banner alternates "fresh in your ears" with
* "remember this?". De-duplicates by id and prefers items that have artwork.
*/
function buildHero(recent: MediaItem[], rediscover: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
const recentPool = recent.filter(hasArt);
const rediscoverPool = rediscover.filter(hasArt);
const result: MediaItem[] = [];
const seen = new Set<string>();
const maxLen = Math.max(recentPool.length, rediscoverPool.length);
for (let i = 0; i < maxLen && result.length < 6; i++) {
for (const candidate of [recentPool[i], rediscoverPool[i]]) {
if (candidate && !seen.has(candidate.id)) {
seen.add(candidate.id);
result.push(candidate);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover] = await Promise.all([
repo.getRecentlyPlayedAudio(SECTION_LIMIT),
repo.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"],
sortBy: "DateCreated",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
}),
repo.getItems(libraryId, {
includeItemTypes: ["Playlist"],
sortBy: "SortName",
sortOrder: "Ascending",
recursive: true,
limit: SECTION_LIMIT,
}),
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
]);
// HACK: drop the "Podcasts" folder that lives inside the music library.
const recentlyPlayedAlbums = excludePodcasts(recentlyPlayed);
const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
const playlistItems = excludePodcasts(playlistsResult.items);
const rediscoverAlbums = excludePodcasts(rediscover);
const heroItems = buildHero(recentlyPlayedAlbums, rediscoverAlbums);
update(s => ({
...s,
recentlyPlayed: recentlyPlayedAlbums,
newlyAdded: newlyAddedAlbums,
playlists: playlistItems,
rediscover: rediscoverAlbums,
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load music sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load music sections:", error);
}
}
/** Fetch up to SECTION_LIMIT albums for one genre as a slider row. */
async function loadGenreRow(libraryId: string, genre: Genre): Promise<GenreRow> {
const repo = auth.getRepository();
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"],
genres: [genre.name],
sortBy: "SortName",
sortOrder: "Ascending",
recursive: true,
limit: SECTION_LIMIT,
});
// HACK: drop the "Podcasts" folder that lives in the music library.
return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
}
/**
* Build one slider per genre, showing albums in each. We pick a *diverse* set
* rather than just the most populous otherwise a cluster of near-synonyms
* ("Rock", "Hard Rock", "Classic Rock", ...) crowds out musically distinct
* genres. See selectDiverseGenres. Album genres rarely carry community
* ratings, so we order each row by name.
*
* When the backend reports per-genre album counts (online), we rank and pick
* before fetching, so we only query albums for the genres we'll actually
* show. When counts are missing (offline), we fall back to probing a wide
* pool, dropping empties, then ranking by what came back.
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
// Counts are only *useful* if they actually differentiate genres. Some
// servers return Fields=ItemCounts but populate every genre with the same
// value (or 0), which leaves the list in its original alphabetical order
// after "ranking" — so diversity selection seeds on the first genre and we
// get a wall of A-genres ("avangard", "avan-gard", ...) with no Rock.
// Treat that as "no usable counts" and fall through to the probe path,
// which ranks by genres' real album counts instead.
const positiveCounts = genres
.map(g => g.albumCount)
.filter((c): c is number => c != null && c > 0);
const hasUsefulCounts = new Set(positiveCounts).size > 1;
let genreRows: GenreRow[];
if (hasUsefulCounts) {
// Rank by reported count, pick a diverse subset, then fetch only those.
const ranked = [...genres].sort(
(a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0)
);
const chosen = selectDiverseGenres(ranked, MAX_GENRE_ROWS);
genreRows = (await Promise.all(chosen.map(g => loadGenreRow(libraryId, g)))).filter(
row => row.items.length > 0
);
} else {
// No usable counts (offline, a server that ignores Fields=ItemCounts,
// or one that returns uniform/zero counts — see hasUsefulCounts above).
// The genre list is alphabetical, so probing the first N would only
// ever surface A-genres. Sample at an even stride across the whole
// list instead, so the probe pool spans A→Z; then drop empties, rank
// by what came back, and pick a diverse subset.
const probed = sampleAcross(genres, MAX_GENRES_PROBED);
const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g)));
const populated = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length);
genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS);
}
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load music genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const music = createMusicStore();
export const musicHeroItems = derived(music, $m => $m.heroItems);
export const isMusicLoading = derived(music, $m => $m.isLoading);
+94 -3
View File
@@ -9,26 +9,31 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}));
// Mock the sessions store
// Mock the sessions store. `selectedSession` is read via svelte's `get()`, which
// calls subscribe and synchronously receives the current value; tests set
// `currentSelectedSession` to control what the store yields.
const mockSelectSession = vi.fn();
let currentSelectedSession: unknown = null;
vi.mock("./sessions", () => ({
sessions: {
selectSession: (...args: unknown[]) => mockSelectSession(...args),
},
selectedSession: {
subscribe: vi.fn((callback: (value: null) => void) => {
callback(null);
subscribe: vi.fn((callback: (value: unknown) => void) => {
callback(currentSelectedSession);
return () => {};
}),
},
}));
// Mock auth store
const mockGetHandle = vi.fn(() => "repo-handle-1");
vi.mock("./auth", () => ({
auth: {
getRepository: vi.fn(() => ({
getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }),
getImageUrl: vi.fn().mockReturnValue("http://test.com/image"),
getHandle: () => mockGetHandle(),
})),
},
}));
@@ -36,6 +41,7 @@ vi.mock("./auth", () => ({
describe("playbackMode store", () => {
beforeEach(() => {
vi.clearAllMocks();
currentSelectedSession = null;
});
afterEach(() => {
@@ -161,6 +167,7 @@ describe("playbackMode store", () => {
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", {
sessionId: "session-456",
position: null,
});
});
@@ -204,6 +211,90 @@ describe("playbackMode store", () => {
});
});
describe("transferToLocal", () => {
// 50,000,000 ticks = 5 seconds (10M ticks per second).
const REMOTE_POSITION_TICKS = 50_000_000;
const REMOTE_POSITION_SECONDS = 5;
function setRemoteSessionPlaying() {
currentSelectedSession = {
id: "session-123",
nowPlayingItem: { id: "item-abc", name: "Test Track", runTimeTicks: 1_800_000_000 },
playState: { positionTicks: REMOTE_POSITION_TICKS },
};
}
it("resumes local playback at the remote position, not from 0", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
// Bug 2 guard: the resume position must be passed to play_tracks so the
// backend seeks at load time. Restarting from 0 means startPosition is missing.
const playTracksCall = mockInvoke.mock.calls.find((c) => c[0] === "player_play_tracks");
expect(playTracksCall).toBeDefined();
expect(playTracksCall![1]).toMatchObject({
repositoryHandle: "repo-handle-1",
request: {
trackIds: ["item-abc"],
startIndex: 0,
startPosition: REMOTE_POSITION_SECONDS,
},
});
});
it("does not issue a separate player_seek (no start-at-0-then-jump race)", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
const seekCall = mockInvoke.mock.calls.find((c) => c[0] === "player_seek");
expect(seekCall).toBeUndefined();
});
it("tells Rust to stop the remote session with the current item and position", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_local", {
currentItemId: "item-abc",
positionTicks: REMOTE_POSITION_TICKS,
});
const state = get(playbackMode);
expect(state.mode).toBe("local");
expect(state.remoteSessionId).toBeNull();
expect(state.isTransferring).toBe(false);
});
it("switches to local without playing media when remote has nothing playing", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
currentSelectedSession = { id: "session-123", nowPlayingItem: null, playState: null };
await playbackMode.transferToLocal();
expect(mockInvoke).not.toHaveBeenCalledWith("player_play_tracks", expect.anything());
const state = get(playbackMode);
expect(state.mode).toBe("local");
expect(state.remoteSessionId).toBeNull();
});
});
describe("clearError", () => {
it("should clear transfer error", async () => {
const { playbackMode } = await import("./playbackMode");
+31 -13
View File
@@ -73,7 +73,10 @@ function createPlaybackModeStore() {
* - Polls remote session until track loads
* - Stops local playback
*/
async function transferToRemote(sessionId: string | null | undefined): Promise<void> {
async function transferToRemote(
sessionId: string | null | undefined,
currentPosition?: number,
): Promise<void> {
console.log("[PlaybackMode] Transferring to remote session:", sessionId);
update((s) => ({ ...s, isTransferring: true, transferError: null }));
@@ -88,10 +91,17 @@ function createPlaybackModeStore() {
};
try {
// Pass the caller's current local position so the remote resumes where we
// are. The backend can't reliably read this itself: on Linux video plays in
// the HTML5 <video> element and the MPV backend reports 0. A null override
// falls back to the backend position (correct for Linux audio via MPV).
const positionOverride =
currentPosition !== undefined && currentPosition > 0 ? currentPosition : null;
// Rust handles everything - just wait for it to complete
// It includes its own 5-second timeout for track loading
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId);
await commands.playbackModeTransferToRemote(sessionId ?? "");
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
console.log("[PlaybackMode] Invoke completed successfully");
if (aborted) {
@@ -188,14 +198,24 @@ function createPlaybackModeStore() {
// Get repository for handle (backend will fetch playback info via player_play_tracks)
const repository = auth.getRepository();
// Mark the whole sequence as a transfer in the *Rust* manager too. Without
// this, player_play_tracks sees mode=Remote and casts the track back to the
// remote session instead of playing it locally (the frontend's own
// isTransferring flag is invisible to Rust). Cleared in `finally`.
await commands.playbackModeSetTransferring(true);
// Start local playback (events allowed through because isTransferring=true)
// Use player_play_tracks - backend fetches all metadata from single ID
const repositoryHandle = repository.getHandle();
// Pass the resume position so the backend seeks at load time. Doing the
// seek here (rather than a delayed playerSeek) avoids the race where the
// media isn't loaded yet and the seek is lost, restarting from 0.
await commands.playerPlayTracks(repositoryHandle, {
trackIds: [itemId],
startIndex: 0,
shuffle: false,
startPosition: positionSeconds,
context: {
type: "search",
searchQuery: "",
@@ -204,16 +224,6 @@ function createPlaybackModeStore() {
if (aborted) return;
// Wait briefly for media to load
await new Promise((resolve) => setTimeout(resolve, 500));
// Seek to position if not at the very start
if (positionSeconds > 0.5) {
await commands.playerSeek(positionSeconds);
}
if (aborted) return;
// Let Rust handle stopping remote playback
await commands.playbackModeTransferToLocal(itemId, positionTicks);
@@ -244,6 +254,14 @@ function createPlaybackModeStore() {
console.error("Transfer to local failed:", error);
throw error;
} finally {
// Always lower the Rust transferring flag so it can't stick on if any step
// above threw (transfer_to_local lowers it on success, but not if we never
// reached it). Safe to call unconditionally.
try {
await commands.playbackModeSetTransferring(false);
} catch (e) {
console.warn("[PlaybackMode] Failed to clear transferring flag:", e);
}
currentTransferAbort = null;
}
}
+10 -6
View File
@@ -13,6 +13,7 @@ import type { MediaItem, ItemType } from "$lib/api/types";
import type { NowPlayingItem } from "$lib/api/bindings";
import { isRemoteMode } from "./playbackMode";
import { selectedSession } from "./sessions";
import { currentQueueItem } from "./queue";
import { ticksToSeconds } from "$lib/utils/playbackUnits";
// Merged media item from backend (matches Rust MergedMediaItem)
@@ -257,21 +258,24 @@ export const mergedVolume = derived(
* AND current media is audio (not video: Movie or Episode)
*/
export const shouldShowAudioMiniPlayer = derived(
[player, currentMedia, isRemoteMode, selectedSession],
([$player, $media, $isRemote, $session]) => {
[player, currentMedia, currentQueueItem, isRemoteMode, selectedSession],
([$player, $media, $queueItem, $isRemote, $session]) => {
// In remote mode, show if the remote session has a now-playing item
if ($isRemote && $session?.nowPlayingItem) {
return true;
}
// Local mode: only show when actively playing or paused
// Local mode: hide only when there is genuinely nothing loaded
// (idle/stopped/error). Keep showing through loading/seeking transitions
// so the mini player doesn't blink out when advancing between tracks.
const state = $player.state;
if (state.kind !== "playing" && state.kind !== "paused") {
if (state.kind === "idle" || state.kind === "error") {
return false;
}
// Don't show for video content
const mediaType = $media?.type;
// Determine media type from the player state, falling back to the queue
// item (the player store can momentarily lack media during transitions).
const mediaType = $media?.type ?? $queueItem?.type;
if (mediaType === "Movie" || mediaType === "Episode") {
return false;
}
+6 -7
View File
@@ -7,8 +7,7 @@
// TRACES: UR-005, UR-015 | DR-005, DR-020
import { writable, derived, get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { commands, events } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -51,12 +50,12 @@ function createQueueStore() {
// Initial sync from backend
await syncFromRust();
// Listen for queue changed events
unlisten = await listen<QueueChangedEvent>("player-event", (event) => {
if ((event.payload as any).type === "queue_changed") {
const queueEvent = event.payload as any;
// Listen for queue changed events (generated tauri-specta event)
unlisten = await events.playerStatusEvent.listen((event) => {
if (event.payload.type === "queue_changed") {
const queueEvent = event.payload;
set({
items: queueEvent.items,
items: queueEvent.items as unknown as MediaItem[],
currentIndex: queueEvent.current_index,
shuffle: queueEvent.shuffle,
repeat: queueEvent.repeat,
+8 -13
View File
@@ -2,8 +2,7 @@
// TRACES: UR-010 | DR-037
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { commands, events } from "$lib/api/bindings";
import type { Session } from "$lib/api/types";
interface SessionsState {
@@ -14,11 +13,6 @@ interface SessionsState {
lastUpdated: Date | null;
}
interface PlayerStatusEvent {
type: string;
sessions?: Session[];
}
function createSessionsStore() {
const initialState: SessionsState = {
sessions: [],
@@ -30,16 +24,17 @@ function createSessionsStore() {
const { subscribe, update } = writable<SessionsState>(initialState);
// Listen for session updates from Rust backend
listen<PlayerStatusEvent>("player-event", (event) => {
if (event.payload.type === "sessions_updated" && event.payload.sessions) {
console.log(`[Sessions] Received ${event.payload.sessions.length} sessions from backend`);
event.payload.sessions.forEach((s, i) => {
// Listen for session updates from Rust backend (generated tauri-specta event)
events.playerStatusEvent.listen((event) => {
if (event.payload.type === "sessions_updated") {
const sessions = event.payload.sessions as unknown as Session[];
console.log(`[Sessions] Received ${sessions.length} sessions from backend`);
sessions.forEach((s, i) => {
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
});
update((s) => ({
...s,
sessions: event.payload.sessions!,
sessions,
lastUpdated: new Date(),
error: null,
}));
+176
View File
@@ -0,0 +1,176 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface TvState {
// Episodes the user can resume (continue watching).
continueWatching: MediaItem[];
// Next unwatched episode per in-progress series.
nextUp: MediaItem[];
// Recently added items in the TV library (series/seasons/episodes).
recentlyAdded: MediaItem[];
// One slider per genre (top genres by show count).
genreRows: GenreRow[];
// Mix used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
const MAX_GENRES_PROBED = 20;
function createTvStore() {
const initialState: TvState = {
continueWatching: [],
nextUp: [],
recentlyAdded: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<TvState>(initialState);
/**
* Build the hero rotation. Prefer in-progress episodes (most personal),
* then fall back to next-up, then recently added. De-duplicates by id and
* prefers items that carry backdrop/primary artwork for a good banner.
*/
function buildHero(
continueWatching: MediaItem[],
nextUp: MediaItem[],
recentlyAdded: MediaItem[]
): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, nextUp, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [resume, nextUp, latest] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
]);
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie");
const heroItems = buildHero(continueWatching, nextUp, latest);
update(s => ({
...s,
continueWatching,
nextUp,
recentlyAdded: latest,
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load TV sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load TV sections:", error);
}
}
/**
* Build one slider per genre, showing the top-rated series in each. We probe
* a bounded set of genres in parallel, drop empty ones, then keep the genres
* with the most shows (so niche/near-empty genres don't crowd the page).
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
const probed = genres.slice(0, MAX_GENRES_PROBED);
const rows = await Promise.all(
probed.map(async (genre): Promise<GenreRow> => {
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["Series"],
genres: [genre.name],
sortBy: "CommunityRating",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
});
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
})
);
const genreRows = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length)
.slice(0, MAX_GENRE_ROWS);
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load TV genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const tv = createTvStore();
export const tvHeroItems = derived(tv, $t => $t.heroItems);
export const isTvLoading = derived(tv, $t => $t.isLoading);
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { selectDiverseGenres, sampleAcross } from "./genreDiversity";
const g = (...names: string[]) => names.map(name => ({ name }));
describe("sampleAcross", () => {
it("returns input unchanged when at or under the count", () => {
expect(sampleAcross([1, 2, 3], 5)).toEqual([1, 2, 3]);
});
it("spreads the sample across the whole list rather than taking a prefix", () => {
// 26 letters, want ~7: a prefix would be AG; striding spans A→Z.
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const picked = sampleAcross(letters, 7);
expect(picked[0]).toBe("A");
// Reaches deep into the alphabet, not stuck near the front.
expect(picked[picked.length - 1] >= "S").toBe(true);
expect(picked.length).toBeLessThanOrEqual(7);
});
it("never exceeds the requested count", () => {
const items = Array.from({ length: 100 }, (_, i) => i);
expect(sampleAcross(items, 8).length).toBeLessThanOrEqual(8);
});
});
describe("selectDiverseGenres", () => {
it("returns input unchanged when at or under the limit", () => {
const input = g("Rock", "Jazz");
expect(selectDiverseGenres(input, 5)).toEqual(input);
});
it("seeds with the most populous genre (first in input)", () => {
const out = selectDiverseGenres(g("Rock", "Jazz", "Hip Hop"), 1);
expect(out.map(x => x.name)).toEqual(["Rock"]);
});
it("spreads the family instead of stacking near-synonyms", () => {
// Count ranking puts all the Rock variants first; a naive top-N would
// return four flavours of Rock. Diversity should pull in distinct genres.
const input = g(
"Rock",
"Hard Rock",
"Classic Rock",
"Pop Rock",
"Jazz",
"Hip Hop",
"Classical"
);
const names = selectDiverseGenres(input, 4).map(x => x.name);
expect(names[0]).toBe("Rock"); // seeded by count
expect(names).toContain("Jazz");
expect(names).toContain("Hip Hop");
expect(names).toContain("Classical");
// Only the seed represents the Rock cluster.
expect(names.filter(n => n.includes("Rock"))).toEqual(["Rock"]);
});
it("preserves count order as the tie-breaker among equally-distinct genres", () => {
// All four are mutually distinct (no shared tokens), so every pick after
// the seed is a distance tie and should follow input (count) order.
const input = g("Rock", "Jazz", "Blues", "Folk");
expect(selectDiverseGenres(input, 3).map(x => x.name)).toEqual([
"Rock",
"Jazz",
"Blues",
]);
});
it("is case- and separator-insensitive when comparing", () => {
const input = g("Hip Hop", "hip-hop", "Reggae");
// "Hip Hop" and "hip-hop" share all tokens → the second is redundant.
expect(selectDiverseGenres(input, 2).map(x => x.name)).toEqual([
"Hip Hop",
"Reggae",
]);
});
});
+95
View File
@@ -0,0 +1,95 @@
// Selecting a *diverse* set of genres for the music landing page.
//
// The naive approach ("keep the genres with the most albums") tends to surface
// a cluster of near-synonyms — "Rock", "Hard Rock", "Classic Rock", "Pop Rock"
// — because big umbrella genres and their sub-genres are all populous. The
// result reads as one genre repeated, not a tour of the library.
//
// Instead we pick greedily for *spread*: start from the most populous genre,
// then repeatedly add whichever remaining genre is least similar to everything
// already chosen (a max-min / farthest-point selection). Similarity is word-
// token overlap (Jaccard), so "Hard Rock" stays close to "Rock" but far from
// "Jazz" or "Hip Hop". Count still acts as a gentle tie-breaker so we don't
// promote a one-album novelty genre over a healthy distinct one.
/** Anything with a name and a relative weight (album count) we can rank by. */
export interface DiversityCandidate {
name: string;
}
/**
* Sample up to `count` items at an even stride across `items`. Genre lists come
* back alphabetical, so taking the first N would only ever surface A-genres;
* striding spreads the sample AZ. Always includes the first item.
*/
export function sampleAcross<T>(items: T[], count: number): T[] {
if (items.length <= count) return items.slice();
const stride = Math.max(1, Math.floor(items.length / count));
return items.filter((_, i) => i % stride === 0).slice(0, count);
}
/** Split a genre name into a set of lowercased word tokens. */
function tokenize(name: string): Set<string> {
return new Set(
name
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
);
}
/** Jaccard similarity of two token sets: |A∩B| / |AB|, in [0, 1]. */
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 && b.size === 0) return 1;
let intersection = 0;
for (const t of a) if (b.has(t)) intersection++;
const union = a.size + b.size - intersection;
return union === 0 ? 0 : intersection / union;
}
/**
* Pick up to `limit` genres that are textually distinct from one another,
* preferring more populous genres. Input order is treated as the count ranking
* (most albums first); ties in distance fall back to that order.
*/
export function selectDiverseGenres<T extends DiversityCandidate>(
candidates: T[],
limit: number
): T[] {
if (candidates.length <= limit) return candidates.slice();
const tokens = candidates.map(c => tokenize(c.name));
const chosen: number[] = [];
const remaining = new Set(candidates.map((_, i) => i));
// Seed with the most populous genre (candidates[0]).
chosen.push(0);
remaining.delete(0);
while (chosen.length < limit && remaining.size > 0) {
let best = -1;
// The best candidate is the one *least* similar to its most-similar chosen
// member. We track that max-similarity and minimise it: a genre is only
// "diverse" if it resembles *nothing* already chosen, so looking at the
// single nearest neighbour (as plain farthest-point does) isn't enough —
// "Hard Rock" must stay close to "Rock" even after unrelated "Jazz" is in.
let bestMaxSim = Infinity;
for (const i of remaining) {
let maxSim = 0;
for (const c of chosen) {
const sim = jaccard(tokens[i], tokens[c]);
if (sim > maxSim) maxSim = sim;
}
// Lower max-similarity wins; on a tie keep the earlier (more populous)
// one, guaranteed because `remaining` iterates in insertion order.
if (maxSim < bestMaxSim) {
bestMaxSim = maxSim;
best = i;
}
}
chosen.push(best);
remaining.delete(best);
}
return chosen.map(i => candidates[i]);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { navigateBack } from "./navigation";
const goto = vi.fn();
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args),
}));
describe("navigateBack", () => {
beforeEach(() => {
goto.mockClear();
});
it("pops real history when there is in-app history to go back to", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(3);
navigateBack("/library");
expect(back).toHaveBeenCalledOnce();
expect(goto).not.toHaveBeenCalled();
});
it("falls back to the given path on a fresh deep-link (no history)", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(1);
navigateBack("/library/music");
expect(goto).toHaveBeenCalledWith("/library/music");
expect(back).not.toHaveBeenCalled();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { goto } from "$app/navigation";
/**
* Navigate "back" using real browser/Android history when possible, falling
* back to an explicit path otherwise.
*
* Hardcoded `goto(backPath)` always sends the user to a fixed screen, which
* loses track of where they actually came from (e.g. reaching the genres list
* from different entry points). Preferring `history.back()` keeps the back
* affordance consistent with the platform back gesture and the browser/Android
* hardware back button.
*
* We only use history when there is somewhere to go back to *within the app*.
* On a fresh deep-link (history length 1, or an external referrer) we fall back
* to `fallbackPath` so the user never gets stranded or bounced out of the app.
*/
export function navigateBack(fallbackPath: string): void {
if (canGoBack()) {
history.back();
} else {
goto(fallbackPath);
}
}
/**
* True when there is in-app history to pop. `history.length > 1` means the user
* navigated here from another page in this session rather than landing here
* directly (deep link, refresh, or first load).
*/
function canGoBack(): boolean {
if (typeof history === "undefined") return false;
return history.length > 1;
}
+30
View File
@@ -0,0 +1,30 @@
// HACK: hide "Podcasts" from the music library.
//
// The user stores podcasts inside the music library under a folder/album named
// "Podcasts", so they leak into album/artist/track/playlist queries. Jellyfin's
// item queries here don't give us a clean server-side exclusion for that folder,
// so we filter client-side by name. This is intentionally a blunt instrument:
// anything whose own name, album, or (album) artist is literally "Podcasts" is
// dropped. If the folder is ever renamed, update PODCAST_FOLDER_NAME.
import type { MediaItem } from "$lib/api/types";
const PODCAST_FOLDER_NAME = "podcasts";
function isPodcastName(value: string | null | undefined): boolean {
return value?.trim().toLowerCase() === PODCAST_FOLDER_NAME;
}
/** True when an item belongs to the "Podcasts" folder/album and should be hidden. */
export function isPodcastItem(item: MediaItem): boolean {
return (
isPodcastName(item.name) ||
isPodcastName(item.albumName) ||
isPodcastName(item.albumArtist) ||
(item.artists?.some(isPodcastName) ?? false)
);
}
/** Remove "Podcasts" entries from a list of music items. */
export function excludePodcasts(items: MediaItem[]): MediaItem[] {
return items.filter((item) => !isPodcastItem(item));
}
+17 -10
View File
@@ -17,23 +17,30 @@
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomNav from "$lib/components/BottomNav.svelte";
import { isInitialized, pendingSyncCount, isAndroid, shuffle, repeat, hasNext, hasPrevious, showSleepTimerModal } from "$lib/stores/appState";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
// Shuffle/repeat/next/previous come from the event-driven queue store, the
// single source of truth (updated instantly on queue_changed).
import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue";
let { children } = $props();
onMount(async () => {
// Initialize auth state (restore session from secure storage)
await auth.initialize();
isInitialized.set(true);
// Detect platform (Android needs global mini player)
// Detect platform first (synchronously, before any await) so the global
// mini player's Android visibility gate is correct from the first render.
// Android needs the global mini player because the library layout hides its
// own; if this ran after `await auth.initialize()` the store stayed false
// long enough that the mini player never appeared on library routes.
try {
const platformName = await platform();
const platformName = platform();
isAndroid.set(platformName === "android");
} catch (err) {
console.error("Platform detection failed:", err);
}
// Initialize auth state (restore session from secure storage)
await auth.initialize();
isInitialized.set(true);
// Initialize player event listener for push-based updates
await initPlayerEvents();
@@ -129,10 +136,10 @@
<BottomNav />
{/if}
<!-- Mini Player - show everywhere except on full player page and login -->
<!-- Android: Show on all routes (except player/login) -->
<!-- Mini Player - show everywhere except on full player page, login and settings -->
<!-- Android: Show on all routes (except player/login/settings) -->
<!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) -->
{#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')}
{#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/settings')}
{#if $isAndroid || !$page.url.pathname.startsWith('/library')}
<MiniPlayer
media={$currentMedia}
+49 -1
View File
@@ -4,11 +4,13 @@
import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated } from "$lib/stores/auth";
import { home } from "$lib/stores/home";
import { library, libraries } from "$lib/stores/library";
import { isServerReachable } from "$lib/stores/connectivity";
import { currentMedia } from "$lib/stores/player";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import type { MediaItem, Library } from "$lib/api/types";
// Track if we've done an initial load (plain variable, not reactive)
let hasLoadedOnce = false;
@@ -34,6 +36,9 @@
if ($isAuthenticated) {
await home.loadHomeSections();
if ($libraries.length === 0) {
await library.loadLibraries();
}
hasLoadedOnce = true;
}
});
@@ -68,6 +73,31 @@
}
}
// Playlist libraries live inside the Music landing page, not as top-level shortcuts.
const shortcutLibraries = $derived(
$libraries.filter((lib) => lib.collectionType !== "playlists")
);
function handleLibraryClick(lib: Library) {
// Mirror /library routing: dedicated landing pages need currentLibrary set.
library.setCurrentLibrary(lib);
switch (lib.collectionType) {
case "music":
goto("/library/music");
break;
case "tvshows":
goto("/library/tv");
break;
case "movies":
goto("/library/movies");
break;
default:
library.clearGenres();
goto("/library");
break;
}
}
const heroItems = $derived($home.heroItems);
const resumeItems = $derived($home.resumeItems.filter(
i => i.type === "Movie" || i.type === "Episode"
@@ -92,6 +122,24 @@
<HeroBanner items={heroItems} />
{/if}
<!-- Your Libraries: quick jump into each dedicated landing page -->
{#if shortcutLibraries.length > 0}
<div>
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
<div class="flex gap-4 overflow-x-auto px-4 pb-2">
{#each shortcutLibraries as lib (lib.id)}
<div class="flex-shrink-0">
<MediaCard
item={lib}
size="medium"
onclick={() => handleLibraryClick(lib)}
/>
</div>
{/each}
</div>
</div>
{/if}
<!-- Next Movie -->
{#if resumeMovies.length > 0}
<Carousel
+14 -50
View File
@@ -3,10 +3,11 @@
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { commands } from "$lib/api/bindings";
import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
import { isAndroid } from "$lib/stores/appState";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
@@ -23,36 +24,19 @@
let showFullPlayer = $state(false);
let showOverflowMenu = $state(false);
let showSleepTimerModal = $state(false);
let shuffle = $state(false);
let repeat = $state<"off" | "all" | "one">("off");
let hasNext = $state(false);
let hasPrevious = $state(false);
let isAndroid = $state(false);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let failedAttempts = 0;
const MAX_SILENT_FAILURES = 3; // Don't log errors for first 3 attempts
// Queue status (shuffle, repeat, hasNext, hasPrevious) is event-driven via the
// queue store, which listens for queue_changed events from the backend. This
// updates instantly when toggling shuffle/repeat (no polling lag).
const shuffle = $derived($isShuffle);
const repeat = $derived($repeatMode);
const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore);
// Platform comes from the shared appState store (set once in the root layout),
// so this layout and the root layout never disagree about Android — otherwise
// both can suppress their mini players and none appears. See $lib/stores/appState.
onMount(() => {
// Detect platform
try {
const platformName = platform();
isAndroid = platformName === "android";
} catch (err) {
console.error("Platform detection failed:", err);
}
// Poll for queue status (shuffle, repeat, hasNext, hasPrevious)
// Position/duration come from player events (pushed every 250ms)
// Add a small delay before starting polling to ensure player is ready on Android
const timeoutId = setTimeout(() => {
updateQueueStatus(); // Initial update
pollInterval = setInterval(updateQueueStatus, 1000);
}, 100);
return () => {
clearTimeout(timeoutId);
if (pollInterval) clearInterval(pollInterval);
scrollGuard.cleanup();
};
});
@@ -64,26 +48,6 @@
}
});
async function updateQueueStatus() {
try {
const queue = await commands.playerGetQueue();
// Reset failure counter on success
failedAttempts = 0;
hasNext = queue.hasNext;
hasPrevious = queue.hasPrevious;
shuffle = queue.shuffle;
repeat = queue.repeat as "off" | "all" | "one";
} catch (e) {
failedAttempts++;
// Only log errors after initial attempts (player may still be initializing)
if (failedAttempts > MAX_SILENT_FAILURES) {
console.error("[Queue Status] Error:", e);
}
}
}
async function handleLogout() {
await auth.logout();
library.reset();
@@ -243,7 +207,7 @@
<!-- Main content (with padding for bottom nav bar and mini player) -->
<main
class="flex-1 overflow-y-auto p-4"
style="padding-bottom: {$shouldShowAudioMiniPlayer ? (isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
style="padding-bottom: {$shouldShowAudioMiniPlayer ? ($isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
onscroll={scrollGuard.onScroll}
>
{@render children()}
@@ -251,7 +215,7 @@
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
<!-- Hide on player page since full player is already there -->
{#if !isAndroid && !$page.url.pathname.startsWith('/player/')}
{#if !$isAndroid && !$page.url.pathname.startsWith('/player/')}
<MiniPlayer
media={$currentMedia}
isPlaying={$isPlaying}
+37 -1
View File
@@ -62,6 +62,36 @@
return;
}
// Route to dedicated TV library landing page
if (lib.collectionType === "tvshows") {
library.setCurrentLibrary(lib);
goto("/library/tv");
return;
}
// Route to dedicated movies library landing page
if (lib.collectionType === "movies") {
library.setCurrentLibrary(lib);
goto("/library/movies");
return;
}
// Live TV channels use a dedicated endpoint
if (lib.collectionType === "livetv") {
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadLiveTvChannels();
return;
}
// Plugin "Channels" root list uses a dedicated endpoint
if (lib.collectionType === "channels") {
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadChannels();
return;
}
// For other library types, load items normally
library.setCurrentLibrary(lib);
library.clearGenres();
@@ -83,6 +113,11 @@
if ("type" in item) {
// It's a MediaItem
const mediaItem = item as MediaItem;
// A ChannelFolderItem can be a folder (drill in) or a playable leaf.
if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) {
goto(`/player/${mediaItem.id}`);
return;
}
switch (mediaItem.type) {
case "Series":
case "Movie":
@@ -97,7 +132,8 @@
goto(`/library/${mediaItem.id}`);
break;
case "Episode":
// Episodes play directly
case "TvChannel":
// Episodes and live TV channels play directly
goto(`/player/${mediaItem.id}`);
break;
default:
+34 -3
View File
@@ -2,6 +2,7 @@
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
@@ -23,6 +24,8 @@
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
import GenreTags from "$lib/components/library/GenreTags.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
interface SeasonData {
season: MediaItem;
@@ -184,6 +187,12 @@
goto(`/library/${clickedItem.id}`);
return;
}
// A ChannelFolderItem can be either a folder (drill in) or a playable leaf.
// Route non-folder channel items straight to the player.
if (clickedItem.type === "ChannelFolderItem" && !clickedItem.isFolder) {
goto(`/player/${clickedItem.id}`);
return;
}
switch (clickedItem.type) {
case "Series":
case "Season":
@@ -281,6 +290,13 @@
// Navigate to series page without the episode param
goto(`/library/${itemId}`);
}
function goBack() {
// Prefer real history so the user returns to wherever they came from
// (libraries grid, a list page, a parent album/artist, search, etc.),
// falling back to the libraries overview on a fresh deep-link.
navigateBack("/library");
}
</script>
<div class="relative">
@@ -315,6 +331,9 @@
{:else if item}
<!-- Person Detail View - shown for Person items -->
{#if item.type === "Person"}
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.type === "Series" && focusedEpisode}
@@ -326,8 +345,13 @@
/>
{:else}
<div class="space-y-8">
<!-- Back navigation -->
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<!-- Header with item info -->
<div class="flex gap-6 pt-4">
<div class="flex gap-6">
<!-- Poster -->
<div class="flex-shrink-0 w-48">
{#if item.primaryImageTag}
@@ -362,10 +386,17 @@
{#if item.parentIndexNumber && item.indexNumber}, {/if}
{#if item.indexNumber}Episode {item.indexNumber}{/if}
</p>
{:else if item.productionYear || item.artists?.length}
{:else if item.artistItems?.length || item.artists?.length}
<p class="text-lg text-gray-400 mt-1">
{item.artists?.join(", ") || item.productionYear}
<ArtistLinks
artistItems={item.artistItems}
artists={item.artists}
linkClass="text-lg text-[var(--color-jellyfin)] hover:underline"
textClass="text-gray-400"
/>
</p>
{:else if item.productionYear}
<p class="text-lg text-gray-400 mt-1">{item.productionYear}</p>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More