feat(library): genre sliders, artist links, and navigation utils
- 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:
@@ -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 = ""
|
||||
@@ -208,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
// Playback completed
|
||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
||||
stopPositionUpdates()
|
||||
nativeOnPlaybackEnded()
|
||||
// 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...")
|
||||
@@ -322,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()
|
||||
@@ -552,6 +570,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
endedNotified = false
|
||||
|
||||
// Store metadata for notification updates
|
||||
currentTitle = title
|
||||
|
||||
@@ -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
|
||||
@@ -1531,7 +1535,7 @@ pub async fn player_play_tracks(
|
||||
|
||||
// Play queue
|
||||
let controller = player.0.lock().await;
|
||||
controller.play_queue(media_items, request.start_index)
|
||||
controller.play_queue_from(media_items, request.start_index, request.start_position)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Set queue context
|
||||
|
||||
+10
-2
@@ -356,6 +356,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,
|
||||
@@ -601,11 +604,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") {
|
||||
@@ -845,7 +854,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");
|
||||
}
|
||||
|
||||
@@ -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>>>,
|
||||
@@ -195,7 +217,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 +231,11 @@ impl PlaybackModeManager {
|
||||
}
|
||||
|
||||
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
||||
let position = state.position().unwrap_or(0.0);
|
||||
// Read the LIVE backend position, not the snapshot embedded in PlayerState.
|
||||
// On Android the embedded position is only refreshed on play/pause
|
||||
// transitions, so PlayerState::position() is stale (often 0) mid-track;
|
||||
// PlayerController::position() always reflects the backend's current time.
|
||||
let position = player.position();
|
||||
let context = queue.context().clone();
|
||||
|
||||
log::info!(
|
||||
@@ -272,12 +297,8 @@ impl PlaybackModeManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate position in ticks
|
||||
let start_position_ticks = if position_seconds > 0.5 {
|
||||
Some((position_seconds * 10_000_000.0) as i64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// 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 {
|
||||
@@ -577,6 +598,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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -864,7 +864,7 @@ impl MediaRepository for OfflineRepository {
|
||||
|
||||
let genres = genre_set
|
||||
.into_iter()
|
||||
.map(|name| Genre { id: name.clone(), name })
|
||||
.map(|name| Genre { id: name.clone(), name, album_count: None })
|
||||
.collect();
|
||||
|
||||
Ok(genres)
|
||||
|
||||
@@ -811,7 +811,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));
|
||||
@@ -828,17 +833,34 @@ 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();
|
||||
log::debug!(
|
||||
"get_genres: {} genres, {} carry counts (AlbumCount/ChildCount)",
|
||||
genres.len(),
|
||||
with_counts
|
||||
);
|
||||
|
||||
Ok(genres)
|
||||
}
|
||||
|
||||
async fn search(
|
||||
|
||||
@@ -255,6 +255,10 @@ pub struct PlaybackInfo {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user