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
This commit is contained in:
2026-06-25 19:18:06 +02:00
parent 62874564ff
commit 1836615dc0
38 changed files with 1009 additions and 207 deletions
+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
+7 -7
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,8 +21,8 @@ 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)]
#[serde(tag = "type", rename_all = "snake_case")]
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")]
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
PositionUpdate {
@@ -113,9 +114,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 +139,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);
}
}
+68 -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,6 +344,15 @@ 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(())
@@ -1333,6 +1362,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),