Merge pull request 'feat(library): genre sliders, artist links, and navigation utils' (#5) from feat/library-genre-sliders-and-nav-utils into master
All checks were successful
🏗️ 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
All checks were successful
🏗️ 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
This commit is contained in:
commit
ff8f35084b
@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Current media ID being played */
|
/** Current media ID being played */
|
||||||
private var currentMediaId: String? = null
|
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 */
|
/** Current media metadata for notification updates */
|
||||||
private var currentTitle: String = ""
|
private var currentTitle: String = ""
|
||||||
private var currentArtist: String = ""
|
private var currentArtist: String = ""
|
||||||
@ -208,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Playback completed
|
// Playback completed
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
||||||
stopPositionUpdates()
|
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 -> {
|
Player.STATE_BUFFERING -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
||||||
@ -322,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
fun load(url: String, mediaId: String) {
|
fun load(url: String, mediaId: String) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
exoPlayer.setMediaItem(mediaItem)
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
exoPlayer.prepare()
|
exoPlayer.prepare()
|
||||||
@ -552,6 +570,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
) {
|
) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
|
|
||||||
// Store metadata for notification updates
|
// Store metadata for notification updates
|
||||||
currentTitle = title
|
currentTitle = title
|
||||||
|
|||||||
@ -41,12 +41,14 @@ pub fn playback_mode_is_transferring(
|
|||||||
pub async fn playback_mode_transfer_to_remote(
|
pub async fn playback_mode_transfer_to_remote(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
position: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[PlaybackModeCommands] Transferring to remote session: {}",
|
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
|
||||||
session_id
|
session_id,
|
||||||
|
position
|
||||||
);
|
);
|
||||||
manager.0.transfer_to_remote(session_id).await
|
manager.0.transfer_to_remote(session_id, position).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer playback from remote session back to local device
|
/// Transfer playback from remote session back to local device
|
||||||
|
|||||||
@ -224,6 +224,10 @@ pub struct PlayTracksRequest {
|
|||||||
pub start_index: usize,
|
pub start_index: usize,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
pub context: PlayTracksContext,
|
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
|
/// 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
|
/// Play a track from an album - backend fetches all album tracks and builds queue
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -1254,6 +1311,7 @@ pub async fn player_play_album_track(
|
|||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
request: PlayAlbumTrackRequest,
|
request: PlayAlbumTrackRequest,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@ -1376,11 +1434,24 @@ pub async fn player_play_album_track(
|
|||||||
session_mgr.start_audio_session(first_item.clone());
|
session_mgr.start_audio_session(first_item.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play the queue
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller
|
|
||||||
.play_queue(media_items, start_index)
|
// When controlling a remote session, cast the selection there instead of
|
||||||
.map_err(|e| e.to_string())?;
|
// 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
|
// Set the queue context for remote transfer
|
||||||
{
|
{
|
||||||
@ -1422,6 +1493,7 @@ pub async fn player_play_tracks(
|
|||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
request: PlayTracksRequest,
|
request: PlayTracksRequest,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@ -1529,10 +1601,33 @@ pub async fn player_play_tracks(
|
|||||||
session_mgr.start_audio_session(first_item.clone());
|
session_mgr.start_audio_session(first_item.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play queue
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.play_queue(media_items, request.start_index)
|
|
||||||
.map_err(|e| e.to_string())?;
|
// 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
|
// Set queue context
|
||||||
{
|
{
|
||||||
|
|||||||
@ -82,6 +82,7 @@ impl ConnectivityReporter {
|
|||||||
|
|
||||||
/// Current reachability as seen by this reporter (shared with the monitor
|
/// Current reachability as seen by this reporter (shared with the monitor
|
||||||
/// and the UI). Useful for callers that want to branch on connectivity.
|
/// 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 {
|
pub async fn is_reachable(&self) -> bool {
|
||||||
self.status.read().await.is_server_reachable
|
self.status.read().await.is_server_reachable
|
||||||
}
|
}
|
||||||
|
|||||||
@ -284,22 +284,35 @@ impl JellyfinClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Seek on a remote session
|
/// 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(
|
pub async fn session_seek(
|
||||||
&self,
|
&self,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
position_ticks: i64,
|
position_ticks: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
#[derive(serde::Serialize)]
|
let url = format!(
|
||||||
#[serde(rename_all = "PascalCase")]
|
"{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
|
||||||
struct SeekRequest {
|
self.config.server_url, session_id, position_ticks
|
||||||
seek_position_ticks: i64,
|
);
|
||||||
|
|
||||||
|
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 {
|
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
|
||||||
seek_position_ticks: position_ticks,
|
Ok(())
|
||||||
};
|
|
||||||
|
|
||||||
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a full GeneralCommand to a remote session.
|
/// Send a full GeneralCommand to a remote session.
|
||||||
|
|||||||
@ -356,6 +356,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
// Throw on error so generated `commands.*` return Promise<T> and throw,
|
// Throw on error so generated `commands.*` return Promise<T> and throw,
|
||||||
// matching the existing frontend's invoke() try/catch convention.
|
// matching the existing frontend's invoke() try/catch convention.
|
||||||
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
|
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
|
||||||
|
.events(tauri_specta::collect_events![
|
||||||
|
crate::player::events::PlayerStatusEvent
|
||||||
|
])
|
||||||
.commands(tauri_specta::collect_commands![
|
.commands(tauri_specta::collect_commands![
|
||||||
// Player commands
|
// Player commands
|
||||||
player_play_item,
|
player_play_item,
|
||||||
@ -601,11 +604,17 @@ pub fn run() {
|
|||||||
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
|
// `.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.
|
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
|
||||||
let builder = specta_builder();
|
let builder = specta_builder();
|
||||||
|
let invoke_handler = builder.invoke_handler();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_os::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
|
// Initialize database with proper app data directory
|
||||||
// Check for test mode environment variable first
|
// Check for test mode environment variable first
|
||||||
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
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");
|
info!("[INIT] Application setup completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(builder.invoke_handler())
|
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,28 @@ pub enum PlaybackMode {
|
|||||||
Idle,
|
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
|
/// Manages playback mode transfers between local and remote sessions
|
||||||
pub struct PlaybackModeManager {
|
pub struct PlaybackModeManager {
|
||||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
@ -159,7 +181,11 @@ impl PlaybackModeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer playback from local device to remote Jellyfin session
|
/// 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] transfer_to_remote ENTERED");
|
||||||
debug!("[PlaybackMode] session_id: {}", session_id);
|
debug!("[PlaybackMode] session_id: {}", session_id);
|
||||||
log::info!(
|
log::info!(
|
||||||
@ -173,7 +199,7 @@ impl PlaybackModeManager {
|
|||||||
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
||||||
|
|
||||||
// Perform the transfer
|
// 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
|
// Clear transferring flag
|
||||||
self.is_transferring.store(false, Ordering::Relaxed);
|
self.is_transferring.store(false, Ordering::Relaxed);
|
||||||
@ -181,12 +207,24 @@ impl PlaybackModeManager {
|
|||||||
result
|
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");
|
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
||||||
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
|
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
|
// 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...");
|
log::info!("[PlaybackMode] Acquiring player controller lock...");
|
||||||
debug!("[PlaybackMode] Acquiring player controller lock...");
|
debug!("[PlaybackMode] Acquiring player controller lock...");
|
||||||
let player = self.player_controller.lock().await;
|
let player = self.player_controller.lock().await;
|
||||||
@ -195,7 +233,6 @@ impl PlaybackModeManager {
|
|||||||
|
|
||||||
let queue_arc = player.queue();
|
let queue_arc = player.queue();
|
||||||
let queue = queue_arc.lock_safe();
|
let queue = queue_arc.lock_safe();
|
||||||
let state = player.state();
|
|
||||||
|
|
||||||
let original_index = queue.current_index().unwrap_or(0);
|
let original_index = queue.current_index().unwrap_or(0);
|
||||||
let items = queue.items();
|
let items = queue.items();
|
||||||
@ -210,7 +247,19 @@ impl PlaybackModeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
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();
|
let context = queue.context().clone();
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@ -272,12 +321,48 @@ impl PlaybackModeManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate position in ticks
|
// Remote -> remote switch: take the current track and position from the
|
||||||
let start_position_ticks = if position_seconds > 0.5 {
|
// session we're leaving, since the local player is idle and reports 0.
|
||||||
Some((position_seconds * 10_000_000.0) as i64)
|
if let Some(ref prev_session_id) = previous_remote_session {
|
||||||
} else {
|
log::info!(
|
||||||
None
|
"[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 {
|
||||||
|
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)
|
// Log queue context for debugging (context is tracked but we always send track IDs)
|
||||||
match &queue_context {
|
match &queue_context {
|
||||||
@ -392,6 +477,33 @@ impl PlaybackModeManager {
|
|||||||
return Err("Remote session did not load track in time".to_string());
|
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)
|
// Stop local playback (queue should remain intact for remote session)
|
||||||
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
||||||
{
|
{
|
||||||
@ -577,6 +689,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
|
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
|
||||||
mod extract_jellyfin_ids_tests {
|
mod extract_jellyfin_ids_tests {
|
||||||
use crate::player::{MediaItem, MediaSource, MediaType};
|
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
|
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||||
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||||
tauri::async_runtime::spawn(async move {
|
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) => {
|
Ok(AutoplayDecision::Stop) => {
|
||||||
log::debug!("[Autoplay] Decision: Stop playback");
|
log::debug!("[Autoplay] Decision: Stop playback");
|
||||||
// Emit PlaybackEnded event to frontend
|
// Emit PlaybackEnded event to frontend
|
||||||
|
|||||||
@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe;
|
|||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::AppHandle;
|
||||||
|
use tauri_specta::Event;
|
||||||
|
|
||||||
use super::{MediaSessionType, SleepTimerMode};
|
use super::{MediaSessionType, SleepTimerMode};
|
||||||
|
|
||||||
@ -20,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
|
|||||||
/// state machine transitions.
|
/// state machine transitions.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
/// 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")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum PlayerStatusEvent {
|
pub enum PlayerStatusEvent {
|
||||||
/// Playback position updated (emitted periodically during playback)
|
/// 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.
|
/// Trait for emitting player events to the frontend.
|
||||||
///
|
///
|
||||||
/// This abstraction allows backends to emit events without depending
|
/// This abstraction allows backends to emit events without depending
|
||||||
@ -141,7 +146,9 @@ impl TauriEventEmitter {
|
|||||||
|
|
||||||
impl PlayerEventEmitter for TauriEventEmitter {
|
impl PlayerEventEmitter for TauriEventEmitter {
|
||||||
fn emit(&self, event: PlayerStatusEvent) {
|
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);
|
error!("Failed to emit player event: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -311,7 +311,27 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Set the queue and start playing from the specified index
|
/// Set the queue and start playing from the specified index
|
||||||
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
|
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
|
// Reset autoplay counter on manual queue start
|
||||||
self.reset_autoplay_count();
|
self.reset_autoplay_count();
|
||||||
@ -324,11 +344,37 @@ impl PlayerController {
|
|||||||
// Play the current item (without modifying the queue we just set)
|
// Play the current item (without modifying the queue we just set)
|
||||||
if let Some(item) = self.queue.lock_safe().current().cloned() {
|
if let Some(item) = self.queue.lock_safe().current().cloned() {
|
||||||
self.load_and_play(&item)?;
|
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(())
|
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
|
/// Play/resume playback
|
||||||
pub fn play(&self) -> Result<(), PlayerError> {
|
pub fn play(&self) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play");
|
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]
|
#[test]
|
||||||
fn test_seek_to_zero() {
|
fn test_seek_to_zero() {
|
||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
|
|||||||
@ -61,7 +61,12 @@ pub enum PlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub fn position(&self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
PlayerState::Playing { position, .. } => Some(*position),
|
PlayerState::Playing { position, .. } => Some(*position),
|
||||||
|
|||||||
@ -830,41 +830,57 @@ impl MediaRepository for OfflineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
// Extract unique genres from cached items
|
// Derive genres from cached albums, tallying how many albums carry each
|
||||||
let mut genre_set = std::collections::HashSet::new();
|
// 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 {
|
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![
|
vec![
|
||||||
QueryParam::String(self.server_id.clone()),
|
QueryParam::String(self.server_id.clone()),
|
||||||
QueryParam::String(pid.to_string()),
|
QueryParam::String(pid.to_string()),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL",
|
"SELECT genres FROM items WHERE server_id = ? \
|
||||||
vec![QueryParam::String(self.server_id.clone())]
|
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
|
||||||
|
vec![QueryParam::String(self.server_id.clone())],
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(sql, params);
|
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
|
.await
|
||||||
.map_err(|e| RepoError::Database { message: e })?;
|
.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 {
|
for genres_json in genres_rows {
|
||||||
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
|
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 {
|
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()
|
.into_iter()
|
||||||
.map(|name| Genre { id: name.clone(), name })
|
.map(|(name, count)| Genre {
|
||||||
|
id: name.clone(),
|
||||||
|
name,
|
||||||
|
album_count: Some(count),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(genres)
|
Ok(genres)
|
||||||
|
|||||||
@ -580,8 +580,10 @@ impl MediaRepository for OnlineRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request image fields for list views (People only needed in get_item detail view)
|
// Request image fields for list views (People only needed in get_item
|
||||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
|
// 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?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
|
|
||||||
@ -811,7 +813,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
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 {
|
if let Some(pid) = parent_id {
|
||||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
||||||
@ -828,17 +835,41 @@ impl MediaRepository for OnlineRepository {
|
|||||||
struct JellyfinGenre {
|
struct JellyfinGenre {
|
||||||
id: String,
|
id: String,
|
||||||
name: 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?;
|
let response: GenresResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(response
|
let genres: Vec<Genre> = response
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|g| Genre {
|
.map(|g| Genre {
|
||||||
id: g.id,
|
id: g.id,
|
||||||
name: g.name,
|
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(
|
async fn search(
|
||||||
@ -868,8 +899,9 @@ impl MediaRepository for OnlineRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request image fields for list views (People only needed in get_item detail view)
|
// Request image fields for list views (plus Genres so cached items
|
||||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
|
// carry genres for offline genre lists/counts).
|
||||||
|
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
|
|||||||
@ -255,6 +255,10 @@ pub struct PlaybackInfo {
|
|||||||
pub struct Genre {
|
pub struct Genre {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: 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
|
/// Image type
|
||||||
|
|||||||
@ -303,8 +303,8 @@ async playbackModeIsTransferring() : Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Transfer playback from local device to a remote Jellyfin session
|
* Transfer playback from local device to a remote Jellyfin session
|
||||||
*/
|
*/
|
||||||
async playbackModeTransferToRemote(sessionId: string) : Promise<null> {
|
async playbackModeTransferToRemote(sessionId: string, position: number | null) : Promise<null> {
|
||||||
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId });
|
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId, position });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Get remote session status (for polling position/duration)
|
* Get remote session status (for polling position/duration)
|
||||||
@ -1276,6 +1276,11 @@ async convertPercentToVolume(percent: number) : Promise<number> {
|
|||||||
/** user-defined events **/
|
/** user-defined events **/
|
||||||
|
|
||||||
|
|
||||||
|
export const events = __makeEvents__<{
|
||||||
|
playerStatusEvent: PlayerStatusEvent
|
||||||
|
}>({
|
||||||
|
playerStatusEvent: "player-status-event"
|
||||||
|
})
|
||||||
|
|
||||||
/** user-defined constants **/
|
/** user-defined constants **/
|
||||||
|
|
||||||
@ -1471,7 +1476,13 @@ export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStat
|
|||||||
/**
|
/**
|
||||||
* Genre
|
* 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)
|
* Request to get an image URL (with caching)
|
||||||
*/
|
*/
|
||||||
@ -1610,7 +1621,12 @@ export type PlayTracksContext = { type: "playlist"; playlistId: string; playlist
|
|||||||
/**
|
/**
|
||||||
* Request to play tracks by ID (backend fetches metadata)
|
* 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
|
* Playback information
|
||||||
*/
|
*/
|
||||||
@ -1791,6 +1807,67 @@ mergedIsPlaying: boolean;
|
|||||||
* Volume from either local player or remote session (0-1 normalized)
|
* Volume from either local player or remote session (0-1 normalized)
|
||||||
*/
|
*/
|
||||||
mergedVolume: number }
|
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
|
* Result of creating a playlist
|
||||||
*
|
*
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
// and adds frontend-only unions/helpers that have no backend equivalent.
|
// and adds frontend-only unions/helpers that have no backend equivalent.
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
ArtistItem,
|
||||||
AuthResult,
|
AuthResult,
|
||||||
Genre,
|
Genre,
|
||||||
GetItemsOptions,
|
GetItemsOptions,
|
||||||
|
|||||||
@ -26,17 +26,28 @@
|
|||||||
|
|
||||||
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
||||||
|
|
||||||
// The strip stretches to fill the height of its scroll container (the page's
|
// The strip stretches from where it sits down to just above the bottom nav /
|
||||||
// <main>, which sits below the sticky header) from the sticky top offset down
|
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
|
||||||
// to the bottom gap reserved for the nav / mini-player bars. We measure the
|
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
|
||||||
// container's visible height live so it stays correct regardless of header
|
// top against that floor (clamped to non-negative) and update on scroll/resize
|
||||||
// size, platform, or window resizes.
|
// so it never slides under the bars regardless of header or platform.
|
||||||
let container = $state<HTMLDivElement | null>(null);
|
let container = $state<HTMLDivElement | null>(null);
|
||||||
let viewportHeight = $state(0);
|
let stripHeight = $state(0);
|
||||||
|
|
||||||
function measure() {
|
function measure() {
|
||||||
const scroller = container?.closest("main") ?? document.documentElement;
|
if (!container) return;
|
||||||
viewportHeight = scroller.clientHeight;
|
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(() => {
|
$effect(() => {
|
||||||
@ -44,13 +55,21 @@
|
|||||||
const scroller = container?.closest("main");
|
const scroller = container?.closest("main");
|
||||||
const ro = new ResizeObserver(measure);
|
const ro = new ResizeObserver(measure);
|
||||||
if (scroller) ro.observe(scroller);
|
if (scroller) ro.observe(scroller);
|
||||||
|
scroller?.addEventListener("scroll", measure, { passive: true });
|
||||||
window.addEventListener("resize", measure);
|
window.addEventListener("resize", measure);
|
||||||
return () => {
|
return () => {
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
|
scroller?.removeEventListener("scroll", measure);
|
||||||
window.removeEventListener("resize", measure);
|
window.removeEventListener("resize", measure);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||||
|
$effect(() => {
|
||||||
|
bottomGap;
|
||||||
|
measure();
|
||||||
|
});
|
||||||
|
|
||||||
const letters = $derived([HASH, ...ALPHABET]);
|
const letters = $derived([HASH, ...ALPHABET]);
|
||||||
let activeLetter = $state<string | null>(null);
|
let activeLetter = $state<string | null>(null);
|
||||||
|
|
||||||
@ -99,7 +118,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
class="flex flex-col items-center justify-between select-none touch-none py-1"
|
class="flex flex-col items-center justify-between select-none touch-none py-1"
|
||||||
style="height: calc({viewportHeight}px - 1.5rem - {bottomGap})"
|
style="height: {stripHeight}px"
|
||||||
onpointerdown={handlePointerDown}
|
onpointerdown={handlePointerDown}
|
||||||
onpointermove={handlePointerMove}
|
onpointermove={handlePointerMove}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
|
|||||||
54
src/lib/components/library/ArtistLinks.svelte
Normal file
54
src/lib/components/library/ArtistLinks.svelte
Normal file
@ -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}
|
||||||
57
src/lib/components/library/ArtistLinks.test.ts
Normal file
57
src/lib/components/library/ArtistLinks.test.ts
Normal file
@ -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">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { navigateBack } from "$lib/utils/navigation";
|
||||||
import { currentLibrary } from "$lib/stores/library";
|
import { currentLibrary } from "$lib/stores/library";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||||
@ -125,7 +126,7 @@
|
|||||||
selectedGenre = null;
|
selectedGenre = null;
|
||||||
genreItems = [];
|
genreItems = [];
|
||||||
} else {
|
} else {
|
||||||
goto(config.backPath);
|
navigateBack(config.backPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { navigateBack } from "$lib/utils/navigation";
|
||||||
import { currentLibrary } from "$lib/stores/library";
|
import { currentLibrary } from "$lib/stores/library";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
||||||
@ -130,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
goto(config.backPath);
|
navigateBack(config.backPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ vi.mock("$lib/stores/queue", () => ({
|
|||||||
setQueue: vi.fn(),
|
setQueue: vi.fn(),
|
||||||
addToQueue: vi.fn(),
|
addToQueue: vi.fn(),
|
||||||
},
|
},
|
||||||
|
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./DownloadButton.svelte", () => ({
|
vi.mock("./DownloadButton.svelte", () => ({
|
||||||
|
|||||||
@ -43,6 +43,18 @@
|
|||||||
let videoElement: HTMLVideoElement | null = $state(null);
|
let videoElement: HTMLVideoElement | null = $state(null);
|
||||||
let isPlaying = $state(false);
|
let isPlaying = $state(false);
|
||||||
let currentTime = $state(0);
|
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 isFullscreen = $state(false);
|
||||||
let showControls = $state(true);
|
let showControls = $state(true);
|
||||||
let showSleepTimerModal = $state(false);
|
let showSleepTimerModal = $state(false);
|
||||||
@ -188,6 +200,7 @@
|
|||||||
seekOffset = 0;
|
seekOffset = 0;
|
||||||
isMediaReady = false; // Reset to loading state when stream URL changes
|
isMediaReady = false; // Reset to loading state when stream URL changes
|
||||||
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
||||||
|
endedFired = false; // New stream loaded - allow onEnded to fire again
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -285,9 +298,7 @@
|
|||||||
if (isNearEnd) {
|
if (isNearEnd) {
|
||||||
// Near end of stream - treat as natural end, don't restart
|
// Near end of stream - treat as natural end, don't restart
|
||||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
||||||
if (onEnded) {
|
notifyEnded();
|
||||||
onEnded();
|
|
||||||
}
|
|
||||||
} else if (hlsFatalRecoveryAttempts <= 3) {
|
} else if (hlsFatalRecoveryAttempts <= 3) {
|
||||||
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||||
hls!.startLoad();
|
hls!.startLoad();
|
||||||
@ -762,9 +773,7 @@
|
|||||||
onReportStop(currentTime, reportMediaId);
|
onReportStop(currentTime, reportMediaId);
|
||||||
}
|
}
|
||||||
// Notify parent that video has ended (for next episode popup)
|
// Notify parent that video has ended (for next episode popup)
|
||||||
if (onEnded) {
|
notifyEnded();
|
||||||
onEnded();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePlayPause() {
|
async function togglePlayPause() {
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
|
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { get } from "svelte/store";
|
||||||
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
||||||
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
||||||
|
import { playbackPosition } from "$lib/stores/player";
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -14,8 +16,8 @@
|
|||||||
|
|
||||||
async function handleSessionSelect(session: Session) {
|
async function handleSessionSelect(session: Session) {
|
||||||
try {
|
try {
|
||||||
// Transfer playback to remote session
|
// Transfer playback to remote session, resuming at our current position
|
||||||
await playbackMode.transferToRemote(session.id);
|
await playbackMode.transferToRemote(session.id, get(playbackPosition));
|
||||||
|
|
||||||
if (onSelectSession) {
|
if (onSelectSession) {
|
||||||
onSelectSession(session);
|
onSelectSession(session);
|
||||||
|
|||||||
@ -8,9 +8,9 @@
|
|||||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
* 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 { type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
|
||||||
import { player, playbackPosition } from "$lib/stores/player";
|
import { player, playbackPosition, currentMedia } from "$lib/stores/player";
|
||||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||||
import { playbackMode } from "$lib/stores/playbackMode";
|
import { playbackMode } from "$lib/stores/playbackMode";
|
||||||
import { sleepTimer } from "$lib/stores/sleepTimer";
|
import { sleepTimer } from "$lib/stores/sleepTimer";
|
||||||
@ -20,33 +20,9 @@ import { preloadUpcomingTracks } from "$lib/services/preload";
|
|||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
/**
|
// PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
|
||||||
* Event types emitted by the player backend.
|
// imported from $lib/api/bindings — they are the authoritative shapes emitted
|
||||||
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs
|
// by 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";
|
|
||||||
|
|
||||||
let unlistenFn: UnlistenFn | null = null;
|
let unlistenFn: UnlistenFn | null = null;
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
@ -62,12 +38,9 @@ export async function initPlayerEvents(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
unlistenFn = await listen<PlayerStatusEvent>(
|
unlistenFn = await events.playerStatusEvent.listen((event) => {
|
||||||
PLAYER_EVENT_NAME,
|
handlePlayerEvent(event.payload);
|
||||||
(event) => {
|
});
|
||||||
handlePlayerEvent(event.payload);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
console.log("Player event listener initialized");
|
console.log("Player event listener initialized");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -152,6 +125,10 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
|
|||||||
case "countdown_tick":
|
case "countdown_tick":
|
||||||
handleCountdownTick(event.remaining_seconds);
|
handleCountdownTick(event.remaining_seconds);
|
||||||
break;
|
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) {
|
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;
|
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
|
// Trigger preloading of upcoming tracks in the background
|
||||||
preloadUpcomingTracks().catch((e) => {
|
preloadUpcomingTracks().catch((e) => {
|
||||||
@ -204,8 +188,6 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
player.setLoading(currentItem);
|
player.setLoading(currentItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update queue status on state change
|
|
||||||
await updateQueueStatus();
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "idle":
|
case "idle":
|
||||||
@ -218,31 +200,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
playbackMode.setMode("idle");
|
playbackMode.setMode("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update queue status on state change
|
|
||||||
await updateQueueStatus();
|
|
||||||
break;
|
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.
|
* Handle media loaded event.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,10 +6,8 @@ import { writable } from 'svelte/store';
|
|||||||
export const isInitialized = writable(false);
|
export const isInitialized = writable(false);
|
||||||
export const pendingSyncCount = writable(0);
|
export const pendingSyncCount = writable(0);
|
||||||
export const isAndroid = writable(false);
|
export const isAndroid = writable(false);
|
||||||
export const shuffle = writable(false);
|
// Shuffle/repeat/next/previous state now lives in the event-driven queue store
|
||||||
export const repeat = writable<'off' | 'all' | 'one'>('off');
|
// ($lib/stores/queue), the single source of truth.
|
||||||
export const hasNext = writable(false);
|
|
||||||
export const hasPrevious = writable(false);
|
|
||||||
export const showSleepTimerModal = writable(false);
|
export const showSleepTimerModal = writable(false);
|
||||||
|
|
||||||
// Library-specific state
|
// Library-specific state
|
||||||
|
|||||||
@ -2,9 +2,17 @@
|
|||||||
// Powers the focused music landing: hero + horizontal sliders.
|
// Powers the focused music landing: hero + horizontal sliders.
|
||||||
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
|
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem, Genre } from "$lib/api/types";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
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 {
|
interface MusicState {
|
||||||
// Albums grouped from recently played tracks (backend handles grouping).
|
// Albums grouped from recently played tracks (backend handles grouping).
|
||||||
@ -15,6 +23,8 @@ interface MusicState {
|
|||||||
playlists: MediaItem[];
|
playlists: MediaItem[];
|
||||||
// Albums the user has played but hasn't returned to in a while.
|
// Albums the user has played but hasn't returned to in a while.
|
||||||
rediscover: MediaItem[];
|
rediscover: MediaItem[];
|
||||||
|
// One slider per genre (top genres by album count).
|
||||||
|
genreRows: GenreRow[];
|
||||||
// Mix of recently played + rediscover, used for the hero banner.
|
// Mix of recently played + rediscover, used for the hero banner.
|
||||||
heroItems: MediaItem[];
|
heroItems: MediaItem[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@ -22,6 +32,11 @@ interface MusicState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_LIMIT = 16;
|
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() {
|
function createMusicStore() {
|
||||||
const initialState: MusicState = {
|
const initialState: MusicState = {
|
||||||
@ -29,6 +44,7 @@ function createMusicStore() {
|
|||||||
newlyAdded: [],
|
newlyAdded: [],
|
||||||
playlists: [],
|
playlists: [],
|
||||||
rediscover: [],
|
rediscover: [],
|
||||||
|
genreRows: [],
|
||||||
heroItems: [],
|
heroItems: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -110,6 +126,10 @@ function createMusicStore() {
|
|||||||
heroItems,
|
heroItems,
|
||||||
isLoading: false,
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
||||||
update(s => ({ ...s, isLoading: false, error: message }));
|
update(s => ({ ...s, isLoading: false, error: message }));
|
||||||
@ -117,6 +137,87 @@ function createMusicStore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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() {
|
function reset() {
|
||||||
set(initialState);
|
set(initialState);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,26 +9,31 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
invoke: (...args: unknown[]) => mockInvoke(...args),
|
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();
|
const mockSelectSession = vi.fn();
|
||||||
|
let currentSelectedSession: unknown = null;
|
||||||
vi.mock("./sessions", () => ({
|
vi.mock("./sessions", () => ({
|
||||||
sessions: {
|
sessions: {
|
||||||
selectSession: (...args: unknown[]) => mockSelectSession(...args),
|
selectSession: (...args: unknown[]) => mockSelectSession(...args),
|
||||||
},
|
},
|
||||||
selectedSession: {
|
selectedSession: {
|
||||||
subscribe: vi.fn((callback: (value: null) => void) => {
|
subscribe: vi.fn((callback: (value: unknown) => void) => {
|
||||||
callback(null);
|
callback(currentSelectedSession);
|
||||||
return () => {};
|
return () => {};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock auth store
|
// Mock auth store
|
||||||
|
const mockGetHandle = vi.fn(() => "repo-handle-1");
|
||||||
vi.mock("./auth", () => ({
|
vi.mock("./auth", () => ({
|
||||||
auth: {
|
auth: {
|
||||||
getRepository: vi.fn(() => ({
|
getRepository: vi.fn(() => ({
|
||||||
getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }),
|
getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }),
|
||||||
getImageUrl: vi.fn().mockReturnValue("http://test.com/image"),
|
getImageUrl: vi.fn().mockReturnValue("http://test.com/image"),
|
||||||
|
getHandle: () => mockGetHandle(),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -36,6 +41,7 @@ vi.mock("./auth", () => ({
|
|||||||
describe("playbackMode store", () => {
|
describe("playbackMode store", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
currentSelectedSession = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -161,6 +167,7 @@ describe("playbackMode store", () => {
|
|||||||
|
|
||||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", {
|
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", {
|
||||||
sessionId: "session-456",
|
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", () => {
|
describe("clearError", () => {
|
||||||
it("should clear transfer error", async () => {
|
it("should clear transfer error", async () => {
|
||||||
const { playbackMode } = await import("./playbackMode");
|
const { playbackMode } = await import("./playbackMode");
|
||||||
|
|||||||
@ -73,7 +73,10 @@ function createPlaybackModeStore() {
|
|||||||
* - Polls remote session until track loads
|
* - Polls remote session until track loads
|
||||||
* - Stops local playback
|
* - 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);
|
console.log("[PlaybackMode] Transferring to remote session:", sessionId);
|
||||||
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
||||||
|
|
||||||
@ -88,10 +91,17 @@ function createPlaybackModeStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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
|
// Rust handles everything - just wait for it to complete
|
||||||
// It includes its own 5-second timeout for track loading
|
// It includes its own 5-second timeout for track loading
|
||||||
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId);
|
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
|
||||||
await commands.playbackModeTransferToRemote(sessionId ?? "");
|
await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
|
||||||
console.log("[PlaybackMode] Invoke completed successfully");
|
console.log("[PlaybackMode] Invoke completed successfully");
|
||||||
|
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
@ -192,10 +202,14 @@ function createPlaybackModeStore() {
|
|||||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
const repositoryHandle = repository.getHandle();
|
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, {
|
await commands.playerPlayTracks(repositoryHandle, {
|
||||||
trackIds: [itemId],
|
trackIds: [itemId],
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
|
startPosition: positionSeconds,
|
||||||
context: {
|
context: {
|
||||||
type: "search",
|
type: "search",
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
@ -204,16 +218,6 @@ function createPlaybackModeStore() {
|
|||||||
|
|
||||||
if (aborted) return;
|
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
|
// Let Rust handle stopping remote playback
|
||||||
await commands.playbackModeTransferToLocal(itemId, positionTicks);
|
await commands.playbackModeTransferToLocal(itemId, positionTicks);
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type { MediaItem, ItemType } from "$lib/api/types";
|
|||||||
import type { NowPlayingItem } from "$lib/api/bindings";
|
import type { NowPlayingItem } from "$lib/api/bindings";
|
||||||
import { isRemoteMode } from "./playbackMode";
|
import { isRemoteMode } from "./playbackMode";
|
||||||
import { selectedSession } from "./sessions";
|
import { selectedSession } from "./sessions";
|
||||||
|
import { currentQueueItem } from "./queue";
|
||||||
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
||||||
|
|
||||||
// Merged media item from backend (matches Rust MergedMediaItem)
|
// 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)
|
* AND current media is audio (not video: Movie or Episode)
|
||||||
*/
|
*/
|
||||||
export const shouldShowAudioMiniPlayer = derived(
|
export const shouldShowAudioMiniPlayer = derived(
|
||||||
[player, currentMedia, isRemoteMode, selectedSession],
|
[player, currentMedia, currentQueueItem, isRemoteMode, selectedSession],
|
||||||
([$player, $media, $isRemote, $session]) => {
|
([$player, $media, $queueItem, $isRemote, $session]) => {
|
||||||
// In remote mode, show if the remote session has a now-playing item
|
// In remote mode, show if the remote session has a now-playing item
|
||||||
if ($isRemote && $session?.nowPlayingItem) {
|
if ($isRemote && $session?.nowPlayingItem) {
|
||||||
return true;
|
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;
|
const state = $player.state;
|
||||||
if (state.kind !== "playing" && state.kind !== "paused") {
|
if (state.kind === "idle" || state.kind === "error") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't show for video content
|
// Determine media type from the player state, falling back to the queue
|
||||||
const mediaType = $media?.type;
|
// item (the player store can momentarily lack media during transitions).
|
||||||
|
const mediaType = $media?.type ?? $queueItem?.type;
|
||||||
if (mediaType === "Movie" || mediaType === "Episode") {
|
if (mediaType === "Movie" || mediaType === "Episode") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,7 @@
|
|||||||
// TRACES: UR-005, UR-015 | DR-005, DR-020
|
// TRACES: UR-005, UR-015 | DR-005, DR-020
|
||||||
|
|
||||||
import { writable, derived, get } from "svelte/store";
|
import { writable, derived, get } from "svelte/store";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { commands, events } from "$lib/api/bindings";
|
||||||
import { commands } from "$lib/api/bindings";
|
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
|
||||||
@ -51,12 +50,12 @@ function createQueueStore() {
|
|||||||
// Initial sync from backend
|
// Initial sync from backend
|
||||||
await syncFromRust();
|
await syncFromRust();
|
||||||
|
|
||||||
// Listen for queue changed events
|
// Listen for queue changed events (generated tauri-specta event)
|
||||||
unlisten = await listen<QueueChangedEvent>("player-event", (event) => {
|
unlisten = await events.playerStatusEvent.listen((event) => {
|
||||||
if ((event.payload as any).type === "queue_changed") {
|
if (event.payload.type === "queue_changed") {
|
||||||
const queueEvent = event.payload as any;
|
const queueEvent = event.payload;
|
||||||
set({
|
set({
|
||||||
items: queueEvent.items,
|
items: queueEvent.items as unknown as MediaItem[],
|
||||||
currentIndex: queueEvent.current_index,
|
currentIndex: queueEvent.current_index,
|
||||||
shuffle: queueEvent.shuffle,
|
shuffle: queueEvent.shuffle,
|
||||||
repeat: queueEvent.repeat,
|
repeat: queueEvent.repeat,
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
// TRACES: UR-010 | DR-037
|
// TRACES: UR-010 | DR-037
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { commands, events } from "$lib/api/bindings";
|
||||||
import { commands } from "$lib/api/bindings";
|
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
|
|
||||||
interface SessionsState {
|
interface SessionsState {
|
||||||
@ -14,11 +13,6 @@ interface SessionsState {
|
|||||||
lastUpdated: Date | null;
|
lastUpdated: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayerStatusEvent {
|
|
||||||
type: string;
|
|
||||||
sessions?: Session[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSessionsStore() {
|
function createSessionsStore() {
|
||||||
const initialState: SessionsState = {
|
const initialState: SessionsState = {
|
||||||
sessions: [],
|
sessions: [],
|
||||||
@ -30,16 +24,17 @@ function createSessionsStore() {
|
|||||||
|
|
||||||
const { subscribe, update } = writable<SessionsState>(initialState);
|
const { subscribe, update } = writable<SessionsState>(initialState);
|
||||||
|
|
||||||
// Listen for session updates from Rust backend
|
// Listen for session updates from Rust backend (generated tauri-specta event)
|
||||||
listen<PlayerStatusEvent>("player-event", (event) => {
|
events.playerStatusEvent.listen((event) => {
|
||||||
if (event.payload.type === "sessions_updated" && event.payload.sessions) {
|
if (event.payload.type === "sessions_updated") {
|
||||||
console.log(`[Sessions] Received ${event.payload.sessions.length} sessions from backend`);
|
const sessions = event.payload.sessions as unknown as Session[];
|
||||||
event.payload.sessions.forEach((s, i) => {
|
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}`);
|
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||||
});
|
});
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
sessions: event.payload.sessions!,
|
sessions,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
error: null,
|
error: null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
79
src/lib/utils/genreDiversity.test.ts
Normal file
79
src/lib/utils/genreDiversity.test.ts
Normal 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 A–G; 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
src/lib/utils/genreDiversity.ts
Normal file
95
src/lib/utils/genreDiversity.ts
Normal 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 A→Z. 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| / |A∪B|, 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
src/lib/utils/navigation.test.ts
Normal file
35
src/lib/utils/navigation.test.ts
Normal 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
src/lib/utils/navigation.ts
Normal file
33
src/lib/utils/navigation.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -17,23 +17,30 @@
|
|||||||
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
||||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||||
import BottomNav from "$lib/components/BottomNav.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();
|
let { children } = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Initialize auth state (restore session from secure storage)
|
// Detect platform first (synchronously, before any await) so the global
|
||||||
await auth.initialize();
|
// mini player's Android visibility gate is correct from the first render.
|
||||||
isInitialized.set(true);
|
// Android needs the global mini player because the library layout hides its
|
||||||
|
// own; if this ran after `await auth.initialize()` the store stayed false
|
||||||
// Detect platform (Android needs global mini player)
|
// long enough that the mini player never appeared on library routes.
|
||||||
try {
|
try {
|
||||||
const platformName = await platform();
|
const platformName = platform();
|
||||||
isAndroid.set(platformName === "android");
|
isAndroid.set(platformName === "android");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Platform detection failed:", 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
|
// Initialize player event listener for push-based updates
|
||||||
await initPlayerEvents();
|
await initPlayerEvents();
|
||||||
|
|
||||||
@ -129,10 +136,10 @@
|
|||||||
<BottomNav />
|
<BottomNav />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Mini Player - show everywhere except on full player page and login -->
|
<!-- Mini Player - show everywhere except on full player page, login and settings -->
|
||||||
<!-- Android: Show on all routes (except player/login) -->
|
<!-- Android: Show on all routes (except player/login/settings) -->
|
||||||
<!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) -->
|
<!-- 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')}
|
{#if $isAndroid || !$page.url.pathname.startsWith('/library')}
|
||||||
<MiniPlayer
|
<MiniPlayer
|
||||||
media={$currentMedia}
|
media={$currentMedia}
|
||||||
|
|||||||
@ -4,11 +4,13 @@
|
|||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import { auth, isAuthenticated } from "$lib/stores/auth";
|
import { auth, isAuthenticated } from "$lib/stores/auth";
|
||||||
import { home } from "$lib/stores/home";
|
import { home } from "$lib/stores/home";
|
||||||
|
import { library, libraries } from "$lib/stores/library";
|
||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
import { currentMedia } from "$lib/stores/player";
|
import { currentMedia } from "$lib/stores/player";
|
||||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||||
import Carousel from "$lib/components/home/Carousel.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)
|
// Track if we've done an initial load (plain variable, not reactive)
|
||||||
let hasLoadedOnce = false;
|
let hasLoadedOnce = false;
|
||||||
@ -34,6 +36,9 @@
|
|||||||
|
|
||||||
if ($isAuthenticated) {
|
if ($isAuthenticated) {
|
||||||
await home.loadHomeSections();
|
await home.loadHomeSections();
|
||||||
|
if ($libraries.length === 0) {
|
||||||
|
await library.loadLibraries();
|
||||||
|
}
|
||||||
hasLoadedOnce = true;
|
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 heroItems = $derived($home.heroItems);
|
||||||
const resumeItems = $derived($home.resumeItems.filter(
|
const resumeItems = $derived($home.resumeItems.filter(
|
||||||
i => i.type === "Movie" || i.type === "Episode"
|
i => i.type === "Movie" || i.type === "Episode"
|
||||||
@ -92,6 +122,24 @@
|
|||||||
<HeroBanner items={heroItems} />
|
<HeroBanner items={heroItems} />
|
||||||
{/if}
|
{/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 -->
|
<!-- Next Movie -->
|
||||||
{#if resumeMovies.length > 0}
|
{#if resumeMovies.length > 0}
|
||||||
<Carousel
|
<Carousel
|
||||||
|
|||||||
@ -3,10 +3,11 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { commands } from "$lib/api/bindings";
|
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 { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
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 { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
||||||
@ -23,36 +24,19 @@
|
|||||||
let showFullPlayer = $state(false);
|
let showFullPlayer = $state(false);
|
||||||
let showOverflowMenu = $state(false);
|
let showOverflowMenu = $state(false);
|
||||||
let showSleepTimerModal = $state(false);
|
let showSleepTimerModal = $state(false);
|
||||||
let shuffle = $state(false);
|
// Queue status (shuffle, repeat, hasNext, hasPrevious) is event-driven via the
|
||||||
let repeat = $state<"off" | "all" | "one">("off");
|
// queue store, which listens for queue_changed events from the backend. This
|
||||||
let hasNext = $state(false);
|
// updates instantly when toggling shuffle/repeat (no polling lag).
|
||||||
let hasPrevious = $state(false);
|
const shuffle = $derived($isShuffle);
|
||||||
let isAndroid = $state(false);
|
const repeat = $derived($repeatMode);
|
||||||
|
const hasNext = $derived($hasNextStore);
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
const hasPrevious = $derived($hasPreviousStore);
|
||||||
let failedAttempts = 0;
|
// Platform comes from the shared appState store (set once in the root layout),
|
||||||
const MAX_SILENT_FAILURES = 3; // Don't log errors for first 3 attempts
|
// 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(() => {
|
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 () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
scrollGuard.cleanup();
|
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() {
|
async function handleLogout() {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
library.reset();
|
library.reset();
|
||||||
@ -243,7 +207,7 @@
|
|||||||
<!-- Main content (with padding for bottom nav bar and mini player) -->
|
<!-- Main content (with padding for bottom nav bar and mini player) -->
|
||||||
<main
|
<main
|
||||||
class="flex-1 overflow-y-auto p-4"
|
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}
|
onscroll={scrollGuard.onScroll}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@ -251,7 +215,7 @@
|
|||||||
|
|
||||||
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
|
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
|
||||||
<!-- Hide on player page since full player is already there -->
|
<!-- 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
|
<MiniPlayer
|
||||||
media={$currentMedia}
|
media={$currentMedia}
|
||||||
isPlaying={$isPlaying}
|
isPlaying={$isPlaying}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { navigateBack } from "$lib/utils/navigation";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
|
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
|
||||||
@ -23,6 +24,8 @@
|
|||||||
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
|
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
|
||||||
import GenreTags from "$lib/components/library/GenreTags.svelte";
|
import GenreTags from "$lib/components/library/GenreTags.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.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 {
|
interface SeasonData {
|
||||||
season: MediaItem;
|
season: MediaItem;
|
||||||
@ -281,6 +284,13 @@
|
|||||||
// Navigate to series page without the episode param
|
// Navigate to series page without the episode param
|
||||||
goto(`/library/${itemId}`);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@ -315,6 +325,9 @@
|
|||||||
{:else if item}
|
{:else if item}
|
||||||
<!-- Person Detail View - shown for Person items -->
|
<!-- Person Detail View - shown for Person items -->
|
||||||
{#if item.type === "Person"}
|
{#if item.type === "Person"}
|
||||||
|
<div class="pt-4">
|
||||||
|
<BackButton onClick={goBack} label="Back" />
|
||||||
|
</div>
|
||||||
<PersonDetailView person={item} />
|
<PersonDetailView person={item} />
|
||||||
<!-- Episode Focus View - shown when navigating with ?episode param -->
|
<!-- Episode Focus View - shown when navigating with ?episode param -->
|
||||||
{:else if item.type === "Series" && focusedEpisode}
|
{:else if item.type === "Series" && focusedEpisode}
|
||||||
@ -326,8 +339,13 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
|
<!-- Back navigation -->
|
||||||
|
<div class="pt-4">
|
||||||
|
<BackButton onClick={goBack} label="Back" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header with item info -->
|
<!-- Header with item info -->
|
||||||
<div class="flex gap-6 pt-4">
|
<div class="flex gap-6">
|
||||||
<!-- Poster -->
|
<!-- Poster -->
|
||||||
<div class="flex-shrink-0 w-48">
|
<div class="flex-shrink-0 w-48">
|
||||||
{#if item.primaryImageTag}
|
{#if item.primaryImageTag}
|
||||||
@ -362,10 +380,17 @@
|
|||||||
{#if item.parentIndexNumber && item.indexNumber}, {/if}
|
{#if item.parentIndexNumber && item.indexNumber}, {/if}
|
||||||
{#if item.indexNumber}Episode {item.indexNumber}{/if}
|
{#if item.indexNumber}Episode {item.indexNumber}{/if}
|
||||||
</p>
|
</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">
|
<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>
|
</p>
|
||||||
|
{:else if item.productionYear}
|
||||||
|
<p class="text-lg text-gray-400 mt-1">{item.productionYear}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { navigateBack } from "$lib/utils/navigation";
|
||||||
import { currentLibrary } from "$lib/stores/library";
|
import { currentLibrary } from "$lib/stores/library";
|
||||||
import { movies } from "$lib/stores/movies";
|
import { movies } from "$lib/stores/movies";
|
||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
@ -85,7 +86,7 @@
|
|||||||
<div class="flex items-center justify-between px-4">
|
<div class="flex items-center justify-between px-4">
|
||||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
|
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
|
||||||
<button
|
<button
|
||||||
onclick={() => goto("/library")}
|
onclick={() => navigateBack("/library")}
|
||||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||||
title="Back to libraries"
|
title="Back to libraries"
|
||||||
aria-label="Back to libraries"
|
aria-label="Back to libraries"
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { navigateBack } from "$lib/utils/navigation";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { currentLibrary } from "$lib/stores/library";
|
import { currentLibrary } from "$lib/stores/library";
|
||||||
import { music } from "$lib/stores/music";
|
import { music } from "$lib/stores/music";
|
||||||
@ -86,6 +87,7 @@
|
|||||||
const newlyAdded = $derived($music.newlyAdded);
|
const newlyAdded = $derived($music.newlyAdded);
|
||||||
const playlists = $derived($music.playlists);
|
const playlists = $derived($music.playlists);
|
||||||
const rediscover = $derived($music.rediscover);
|
const rediscover = $derived($music.rediscover);
|
||||||
|
const genreRows = $derived($music.genreRows);
|
||||||
const isLoading = $derived($music.isLoading);
|
const isLoading = $derived($music.isLoading);
|
||||||
const hasContent = $derived(
|
const hasContent = $derived(
|
||||||
heroItems.length > 0 ||
|
heroItems.length > 0 ||
|
||||||
@ -106,7 +108,7 @@
|
|||||||
<div class="flex items-center justify-between px-4">
|
<div class="flex items-center justify-between px-4">
|
||||||
<h1 class="text-3xl font-bold text-white">Music</h1>
|
<h1 class="text-3xl font-bold text-white">Music</h1>
|
||||||
<button
|
<button
|
||||||
onclick={() => goto("/library")}
|
onclick={() => navigateBack("/library")}
|
||||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||||
title="Back to libraries"
|
title="Back to libraries"
|
||||||
aria-label="Back to libraries"
|
aria-label="Back to libraries"
|
||||||
@ -160,6 +162,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- One slider per genre -->
|
||||||
|
{#each genreRows as row (row.id)}
|
||||||
|
<Carousel
|
||||||
|
title={row.name}
|
||||||
|
items={row.items}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
showAll={() => goto("/library/music/genres")}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
{#if !hasContent}
|
{#if !hasContent}
|
||||||
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
|
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { navigateBack } from "$lib/utils/navigation";
|
||||||
import { currentLibrary } from "$lib/stores/library";
|
import { currentLibrary } from "$lib/stores/library";
|
||||||
import { tv } from "$lib/stores/tv";
|
import { tv } from "$lib/stores/tv";
|
||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
@ -92,7 +93,7 @@
|
|||||||
<div class="flex items-center justify-between px-4">
|
<div class="flex items-center justify-between px-4">
|
||||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
|
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
|
||||||
<button
|
<button
|
||||||
onclick={() => goto("/library")}
|
onclick={() => navigateBack("/library")}
|
||||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||||
title="Back to libraries"
|
title="Back to libraries"
|
||||||
aria-label="Back to libraries"
|
aria-label="Back to libraries"
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
|
||||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||||
import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player";
|
import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
@ -47,10 +47,12 @@
|
|||||||
const position = $derived($playbackPosition);
|
const position = $derived($playbackPosition);
|
||||||
const duration = $derived($playbackDuration);
|
const duration = $derived($playbackDuration);
|
||||||
let isPlaying = $state(false);
|
let isPlaying = $state(false);
|
||||||
let shuffle = $state(false);
|
// Shuffle/repeat/next/previous are event-driven via the queue store (instant
|
||||||
let repeat = $state<"off" | "all" | "one">("off");
|
// update on queue_changed), not polled.
|
||||||
let hasNext = $state(false);
|
const shuffle = $derived($isShuffle);
|
||||||
let hasPrevious = $state(false);
|
const repeat = $derived($repeatMode);
|
||||||
|
const hasNext = $derived($hasNextStore);
|
||||||
|
const hasPrevious = $derived($hasPreviousStore);
|
||||||
let currentMedia = $state<MediaItem | null>(null);
|
let currentMedia = $state<MediaItem | null>(null);
|
||||||
let streamUrl = $state<string | null>(null);
|
let streamUrl = $state<string | null>(null);
|
||||||
let mediaSourceId = $state<string | null>(null);
|
let mediaSourceId = $state<string | null>(null);
|
||||||
@ -133,14 +135,7 @@
|
|||||||
isVideo = item.type === "Movie" || item.type === "Episode";
|
isVideo = item.type === "Movie" || item.type === "Episode";
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
// Sync queue status
|
// hasNext/hasPrevious come from the event-driven queue store.
|
||||||
try {
|
|
||||||
const queueStatus = await commands.playerGetQueue();
|
|
||||||
hasNext = queueStatus.hasNext;
|
|
||||||
hasPrevious = queueStatus.hasPrevious;
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore - queue status will update via polling
|
|
||||||
}
|
|
||||||
// Fetch next episode for video skip button
|
// Fetch next episode for video skip button
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
fetchNextEpisode(item);
|
fetchNextEpisode(item);
|
||||||
@ -446,13 +441,7 @@
|
|||||||
isPlaying = status.state.kind === "playing";
|
isPlaying = status.state.kind === "playing";
|
||||||
// Note: position/duration are now derived from player store (updated by events)
|
// Note: position/duration are now derived from player store (updated by events)
|
||||||
}
|
}
|
||||||
shuffle = status.shuffle;
|
// shuffle/repeat/hasNext/hasPrevious come from the queue store (event-driven).
|
||||||
repeat = status.repeat;
|
|
||||||
|
|
||||||
// Update queue status
|
|
||||||
const queue = await commands.playerGetQueue();
|
|
||||||
hasNext = queue.hasNext;
|
|
||||||
hasPrevious = queue.hasPrevious;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore polling errors
|
// Ignore polling errors
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user