feat(library): genre sliders, artist links, and navigation utils
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s

- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
This commit is contained in:
Duncan Tourolle 2026-06-25 19:18:06 +02:00
parent 62874564ff
commit 1836615dc0
38 changed files with 1009 additions and 207 deletions

View File

@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
/** Current media ID being played */
private var currentMediaId: String? = null
/**
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
* end of a transcoded stream), which would otherwise notify the backend
* twice and, for example, decrement the sleep-timer episode counter twice.
* Reset whenever new media is loaded.
*/
private var endedNotified = false
/** Current media metadata for notification updates */
private var currentTitle: String = ""
private var currentArtist: String = ""
@ -208,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) {
// Playback completed
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
stopPositionUpdates()
// Only notify the backend once per loaded media. ExoPlayer
// can re-enter STATE_ENDED, which would double-count things
// like the sleep-timer episode counter.
if (!endedNotified) {
endedNotified = true
nativeOnPlaybackEnded()
} else {
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
}
}
Player.STATE_BUFFERING -> {
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
@ -322,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun load(url: String, mediaId: String) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
@ -552,6 +570,7 @@ class JellyTauPlayer(private val appContext: Context) {
) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
// Store metadata for notification updates
currentTitle = title

View File

@ -224,6 +224,10 @@ pub struct PlayTracksRequest {
pub start_index: usize,
pub shuffle: bool,
pub context: PlayTracksContext,
/// Position (seconds) to resume the starting track from. Used when taking
/// over playback from a remote session so we don't restart from 0.
#[serde(default)]
pub start_position: Option<f64>,
}
/// Context information for track playback
@ -1531,7 +1535,7 @@ pub async fn player_play_tracks(
// Play queue
let controller = player.0.lock().await;
controller.play_queue(media_items, request.start_index)
controller.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
// Set queue context

View File

@ -356,6 +356,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
// Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
.events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
@ -601,11 +604,17 @@ pub fn run() {
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.setup(|app| {
.invoke_handler(invoke_handler)
.setup(move |app| {
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@ -845,7 +854,6 @@ pub fn run() {
info!("[INIT] Application setup completed successfully");
Ok(())
})
.invoke_handler(builder.invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -20,6 +20,28 @@ pub enum PlaybackMode {
Idle,
}
/// Number of Jellyfin ticks per second (100ns units).
const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// Below this many seconds we treat the position as "at the start" and don't
/// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start.
///
/// Pure helper so the resume-position math is unit-testable without a remote
/// session or HTTP. The *source* of `position_seconds` matters too: callers
/// must pass the live backend position (`PlayerController::position()`), not the
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
if position_seconds > RESUME_THRESHOLD_SECONDS {
Some((position_seconds * TICKS_PER_SECOND) as i64)
} else {
None
}
}
/// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@ -195,7 +217,6 @@ impl PlaybackModeManager {
let queue_arc = player.queue();
let queue = queue_arc.lock_safe();
let state = player.state();
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
@ -210,7 +231,11 @@ impl PlaybackModeManager {
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
let position = state.position().unwrap_or(0.0);
// Read the LIVE backend position, not the snapshot embedded in PlayerState.
// On Android the embedded position is only refreshed on play/pause
// transitions, so PlayerState::position() is stale (often 0) mid-track;
// PlayerController::position() always reflects the backend's current time.
let position = player.position();
let context = queue.context().clone();
log::info!(
@ -272,12 +297,8 @@ impl PlaybackModeManager {
}
};
// Calculate position in ticks
let start_position_ticks = if position_seconds > 0.5 {
Some((position_seconds * 10_000_000.0) as i64)
} else {
None
};
// Calculate position in ticks (from the live position read above)
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
@ -577,6 +598,23 @@ mod tests {
);
}
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
#[test]
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
assert_eq!(start_position_ticks_from_seconds(0.5), None);
// Just past the threshold resumes rather than restarting.
assert!(start_position_ticks_from_seconds(0.6).is_some());
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType};

View File

@ -678,7 +678,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
match controller.lock().await.on_playback_ended().await {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend

View File

@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe;
use log::error;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode};
@ -20,8 +21,8 @@ use super::{MediaSessionType, SleepTimerMode};
/// state machine transitions.
///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")]
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
PositionUpdate {
@ -113,9 +114,6 @@ pub enum PlayerStatusEvent {
},
}
/// Tauri event name for player status events
pub const PLAYER_EVENT_NAME: &str = "player-event";
/// Trait for emitting player events to the frontend.
///
/// This abstraction allows backends to emit events without depending
@ -141,7 +139,9 @@ impl TauriEventEmitter {
impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
// Emitted via the tauri-specta Event trait so the payload shape and event
// name match the generated TypeScript bindings (events.playerStatusEvent).
if let Err(e) = Event::emit(&event, &self.app_handle) {
error!("Failed to emit player event: {}", e);
}
}

View File

@ -311,7 +311,27 @@ impl PlayerController {
/// Set the queue and start playing from the specified index
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index);
self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start
self.reset_autoplay_count();
@ -324,6 +344,15 @@ impl PlayerController {
// Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
}
Ok(())
@ -1333,6 +1362,44 @@ mod tests {
}
}
/// Resuming a queue at a position seeks the starting track immediately.
/// Regression guard for taking over a remote session: the local player must
/// pick up where the remote left off, not restart from 0.
#[test]
fn test_play_queue_from_resumes_at_position() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
}
/// A None / near-zero start position starts the track from the beginning.
#[test]
fn test_play_queue_from_without_position_starts_at_zero() {
let controller = PlayerController::default();
controller
.play_queue_from(create_test_items(2), 0, None)
.unwrap();
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
controller
.play_queue_from(create_test_items(2), 0, Some(0.2))
.unwrap();
assert_eq!(
controller.position(),
0.0,
"Sub-threshold resume position is ignored (starts at 0)"
);
}
#[test]
fn test_seek_to_zero() {
let controller = PlayerController::default();

View File

@ -61,7 +61,12 @@ pub enum PlayerState {
}
impl PlayerState {
/// Get the current playback position if available
/// Get the current playback position if available.
///
/// Note: this is the position snapshot embedded in the state at the last
/// state transition, not the live backend position. For an up-to-date
/// value use `PlayerController::position()`.
#[allow(dead_code)]
pub fn position(&self) -> Option<f64> {
match self {
PlayerState::Playing { position, .. } => Some(*position),

View File

@ -864,7 +864,7 @@ impl MediaRepository for OfflineRepository {
let genres = genre_set
.into_iter()
.map(|name| Genre { id: name.clone(), name })
.map(|name| Genre { id: name.clone(), name, album_count: None })
.collect();
Ok(genres)

View File

@ -811,7 +811,12 @@ impl MediaRepository for OnlineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let mut endpoint = format!("/Genres?UserId={}", self.user_id);
// Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
@ -828,17 +833,34 @@ impl MediaRepository for OnlineRepository {
struct JellyfinGenre {
id: String,
name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
}
let response: GenresResponse = self.get_json(&endpoint).await?;
Ok(response
let genres: Vec<Genre> = response
.items
.into_iter()
.map(|g| Genre {
id: g.id,
name: g.name,
album_count: g.album_count.or(g.child_count),
})
.collect())
.collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
log::debug!(
"get_genres: {} genres, {} carry counts (AlbumCount/ChildCount)",
genres.len(),
with_counts
);
Ok(genres)
}
async fn search(

View File

@ -255,6 +255,10 @@ pub struct PlaybackInfo {
pub struct Genre {
pub id: String,
pub name: String,
/// Number of albums tagged with this genre, when the backend can supply it
/// (online only). Lets the frontend rank/pick genres without probing each
/// one. `None` when unknown (e.g. offline).
pub album_count: Option<u32>,
}
/// Image type

View File

@ -1276,6 +1276,11 @@ async convertPercentToVolume(percent: number) : Promise<number> {
/** user-defined events **/
export const events = __makeEvents__<{
playerStatusEvent: PlayerStatusEvent
}>({
playerStatusEvent: "player-status-event"
})
/** user-defined constants **/
@ -1471,7 +1476,13 @@ export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStat
/**
* Genre
*/
export type Genre = { id: string; name: string }
export type Genre = { id: string; name: string;
/**
* Number of albums tagged with this genre, when the backend can supply it
* (online only). Lets the frontend rank/pick genres without probing each
* one. `None` when unknown (e.g. offline).
*/
albumCount: number | null }
/**
* Request to get an image URL (with caching)
*/
@ -1610,7 +1621,12 @@ export type PlayTracksContext = { type: "playlist"; playlistId: string; playlist
/**
* Request to play tracks by ID (backend fetches metadata)
*/
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext }
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext;
/**
* Position (seconds) to resume the starting track from. Used when taking
* over playback from a remote session so we don't restart from 0.
*/
startPosition?: number | null }
/**
* Playback information
*/
@ -1791,6 +1807,67 @@ mergedIsPlaying: boolean;
* Volume from either local player or remote session (0-1 normalized)
*/
mergedVolume: number }
/**
* Events emitted by the player backend to the frontend via Tauri events.
*
* These are distinct from `PlayerEvent` in state.rs, which handles internal
* state machine transitions.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
/**
* Playback position updated (emitted periodically during playback)
*/
{ type: "position_update"; position: number; duration: number } |
/**
* Player state changed
*/
{ type: "state_changed"; state: string; media_id: string | null } |
/**
* Media has finished loading and is ready to play
*/
{ type: "media_loaded"; duration: number } |
/**
* Playback has ended naturally (reached end of media)
*/
{ type: "playback_ended" } |
/**
* Buffering state changed
*/
{ type: "buffering"; percent: number } |
/**
* An error occurred during playback
*/
{ type: "error"; message: string; recoverable: boolean } |
/**
* Volume changed
*/
{ type: "volume_changed"; volume: number; muted: boolean } |
/**
* Sleep timer state changed
*/
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Show next episode popup with countdown
*/
{ type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean } |
/**
* Countdown tick (emitted every second during autoplay countdown)
*/
{ type: "countdown_tick"; remaining_seconds: number } |
/**
* Queue changed (items added, removed, reordered, or playback mode changed)
*/
{ type: "queue_changed"; items: PlayerMediaItem[]; current_index: number | null; shuffle: boolean; repeat: RepeatMode; has_next: boolean; has_previous: boolean } |
/**
* Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
*/
{ type: "session_changed"; session: MediaSessionType } |
/**
* Remote sessions updated (for cast/remote control UI)
*/
{ type: "sessions_updated"; sessions: SessionInfo[] }
/**
* Result of creating a playlist
*

View File

@ -5,6 +5,7 @@
// and adds frontend-only unions/helpers that have no backend equivalent.
export type {
ArtistItem,
AuthResult,
Genre,
GetItemsOptions,

View File

@ -26,17 +26,28 @@
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
// The strip stretches to fill the height of its scroll container (the page's
// <main>, which sits below the sticky header) from the sticky top offset down
// to the bottom gap reserved for the nav / mini-player bars. We measure the
// container's visible height live so it stays correct regardless of header
// size, platform, or window resizes.
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let viewportHeight = $state(0);
let stripHeight = $state(0);
function measure() {
const scroller = container?.closest("main") ?? document.documentElement;
viewportHeight = scroller.clientHeight;
if (!container) return;
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
}
$effect(() => {
@ -44,13 +55,21 @@
const scroller = container?.closest("main");
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
scroller?.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
@ -99,7 +118,7 @@
<div
bind:this={container}
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}
onpointermove={handlePointerMove}
role="navigation"

View 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}

View 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");
});
});

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte";
@ -125,7 +126,7 @@
selectedGenre = null;
genreItems = [];
} else {
goto(config.backPath);
navigateBack(config.backPath);
}
}

View File

@ -2,6 +2,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
@ -130,7 +131,7 @@
}
function goBack() {
goto(config.backPath);
navigateBack(config.backPath);
}
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);

View File

@ -43,6 +43,18 @@
let videoElement: HTMLVideoElement | null = $state(null);
let isPlaying = $state(false);
let currentTime = $state(0);
// Guards against onEnded() firing more than once per loaded stream. For
// transcoded HLS, both the native `ended` event and the "fatal network error
// near end of stream" recovery path can fire for the same playback, which
// would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
// sleep-timer episode counter twice). Reset when the stream URL changes.
let endedFired = $state(false);
function notifyEnded() {
if (endedFired) return;
endedFired = true;
onEnded?.();
}
let isFullscreen = $state(false);
let showControls = $state(true);
let showSleepTimerModal = $state(false);
@ -188,6 +200,7 @@
seekOffset = 0;
isMediaReady = false; // Reset to loading state when stream URL changes
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
endedFired = false; // New stream loaded - allow onEnded to fire again
}
});
@ -285,9 +298,7 @@
if (isNearEnd) {
// Near end of stream - treat as natural end, don't restart
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
if (onEnded) {
onEnded();
}
notifyEnded();
} else if (hlsFatalRecoveryAttempts <= 3) {
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
hls!.startLoad();
@ -762,9 +773,7 @@
onReportStop(currentTime, reportMediaId);
}
// Notify parent that video has ended (for next episode popup)
if (onEnded) {
onEnded();
}
notifyEnded();
}
async function togglePlayPause() {

View File

@ -8,8 +8,8 @@
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { type UnlistenFn } from "@tauri-apps/api/event";
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
import { player, playbackPosition } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode";
@ -20,33 +20,9 @@ import { preloadUpcomingTracks } from "$lib/services/preload";
import type { MediaItem } from "$lib/api/types";
import { get } from "svelte/store";
/**
* Event types emitted by the player backend.
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
| { type: "position_update"; position: number; duration: number }
| { type: "state_changed"; state: string; media_id: string | null }
| { type: "media_loaded"; duration: number }
| { type: "playback_ended" }
| { type: "buffering"; percent: number }
| { type: "error"; message: string; recoverable: boolean }
| { type: "volume_changed"; volume: number; muted: boolean }
| { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number }
| { type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean }
| { type: "countdown_tick"; remaining_seconds: number };
// Sleep timer mode type
export type SleepTimerMode =
| { kind: "off" }
| { kind: "time"; endTime: number }
| { kind: "endOfTrack" }
| { kind: "episodes"; remaining: number };
/** Event name for player status events from backend */
const PLAYER_EVENT_NAME = "player-event";
// PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
// imported from $lib/api/bindings — they are the authoritative shapes emitted
// by src-tauri/src/player/events.rs.
let unlistenFn: UnlistenFn | null = null;
let isInitialized = false;
@ -62,12 +38,9 @@ export async function initPlayerEvents(): Promise<void> {
}
try {
unlistenFn = await listen<PlayerStatusEvent>(
PLAYER_EVENT_NAME,
(event) => {
unlistenFn = await events.playerStatusEvent.listen((event) => {
handlePlayerEvent(event.payload);
}
);
});
isInitialized = true;
console.log("Player event listener initialized");
} catch (e) {
@ -152,6 +125,10 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
case "countdown_tick":
handleCountdownTick(event.remaining_seconds);
break;
// queue_changed is handled by the queue store's own listener
// ($lib/stores/queue), which is the single source of truth for
// shuffle/repeat/next/previous. No action needed here.
}
}
@ -204,8 +181,6 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
player.setLoading(currentItem);
}
// Update queue status on state change
await updateQueueStatus();
break;
case "idle":
@ -218,31 +193,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
playbackMode.setMode("idle");
}
// Update queue status on state change
await updateQueueStatus();
break;
}
}
/**
* Update queue status from backend.
* Called on state changes instead of polling.
*/
async function updateQueueStatus(): Promise<void> {
try {
const queueStatus = await commands.playerGetQueue();
// Import appState stores dynamically to avoid circular imports
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
hasNext.set(queueStatus.hasNext);
hasPrevious.set(queueStatus.hasPrevious);
shuffle.set(queueStatus.shuffle);
repeat.set(queueStatus.repeat as "off" | "all" | "one");
} catch (e) {
console.error("[playerEvents] Failed to update queue status:", e);
}
}
/**
* Handle media loaded event.
*/

View File

@ -6,10 +6,8 @@ import { writable } from 'svelte/store';
export const isInitialized = writable(false);
export const pendingSyncCount = writable(0);
export const isAndroid = writable(false);
export const shuffle = writable(false);
export const repeat = writable<'off' | 'all' | 'one'>('off');
export const hasNext = writable(false);
export const hasPrevious = writable(false);
// Shuffle/repeat/next/previous state now lives in the event-driven queue store
// ($lib/stores/queue), the single source of truth.
export const showSleepTimerModal = writable(false);
// Library-specific state

View File

@ -2,9 +2,17 @@
// Powers the focused music landing: hero + horizontal sliders.
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import type { MediaItem, Genre } from "$lib/api/types";
import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter";
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
/** A single "by genre" row: the genre name plus the albums in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface MusicState {
// Albums grouped from recently played tracks (backend handles grouping).
@ -15,6 +23,8 @@ interface MusicState {
playlists: MediaItem[];
// Albums the user has played but hasn't returned to in a while.
rediscover: MediaItem[];
// One slider per genre (top genres by album count).
genreRows: GenreRow[];
// Mix of recently played + rediscover, used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
@ -22,6 +32,11 @@ interface MusicState {
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
// Probe a wide pool so the diverse-selection step has genres from across the
// whole (alphabetical) list to choose between, not just the first handful.
const MAX_GENRES_PROBED = 40;
function createMusicStore() {
const initialState: MusicState = {
@ -29,6 +44,7 @@ function createMusicStore() {
newlyAdded: [],
playlists: [],
rediscover: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
@ -110,6 +126,10 @@ function createMusicStore() {
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load music sections";
update(s => ({ ...s, isLoading: false, error: message }));
@ -117,6 +137,76 @@ 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;
const hasCounts = genres.some(g => g.albumCount != null);
let genreRows: GenreRow[];
if (hasCounts) {
// 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 counts (offline, or a server that ignores Fields=ItemCounts).
// The genre list is alphabetical, so probing the first N would only
// ever surface A-genres. Sample at an even stride across the whole
// list instead, so the probe pool spans A→Z; then drop empties, rank
// by what came back, and pick a diverse subset.
const probed = sampleAcross(genres, MAX_GENRES_PROBED);
const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g)));
const populated = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length);
genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS);
}
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load music genre rows:", e);
}
}
function reset() {
set(initialState);
}

View File

@ -9,26 +9,31 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}));
// Mock the sessions store
// Mock the sessions store. `selectedSession` is read via svelte's `get()`, which
// calls subscribe and synchronously receives the current value; tests set
// `currentSelectedSession` to control what the store yields.
const mockSelectSession = vi.fn();
let currentSelectedSession: unknown = null;
vi.mock("./sessions", () => ({
sessions: {
selectSession: (...args: unknown[]) => mockSelectSession(...args),
},
selectedSession: {
subscribe: vi.fn((callback: (value: null) => void) => {
callback(null);
subscribe: vi.fn((callback: (value: unknown) => void) => {
callback(currentSelectedSession);
return () => {};
}),
},
}));
// Mock auth store
const mockGetHandle = vi.fn(() => "repo-handle-1");
vi.mock("./auth", () => ({
auth: {
getRepository: vi.fn(() => ({
getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }),
getImageUrl: vi.fn().mockReturnValue("http://test.com/image"),
getHandle: () => mockGetHandle(),
})),
},
}));
@ -36,6 +41,7 @@ vi.mock("./auth", () => ({
describe("playbackMode store", () => {
beforeEach(() => {
vi.clearAllMocks();
currentSelectedSession = null;
});
afterEach(() => {
@ -204,6 +210,90 @@ describe("playbackMode store", () => {
});
});
describe("transferToLocal", () => {
// 50,000,000 ticks = 5 seconds (10M ticks per second).
const REMOTE_POSITION_TICKS = 50_000_000;
const REMOTE_POSITION_SECONDS = 5;
function setRemoteSessionPlaying() {
currentSelectedSession = {
id: "session-123",
nowPlayingItem: { id: "item-abc", name: "Test Track", runTimeTicks: 1_800_000_000 },
playState: { positionTicks: REMOTE_POSITION_TICKS },
};
}
it("resumes local playback at the remote position, not from 0", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
// Bug 2 guard: the resume position must be passed to play_tracks so the
// backend seeks at load time. Restarting from 0 means startPosition is missing.
const playTracksCall = mockInvoke.mock.calls.find((c) => c[0] === "player_play_tracks");
expect(playTracksCall).toBeDefined();
expect(playTracksCall![1]).toMatchObject({
repositoryHandle: "repo-handle-1",
request: {
trackIds: ["item-abc"],
startIndex: 0,
startPosition: REMOTE_POSITION_SECONDS,
},
});
});
it("does not issue a separate player_seek (no start-at-0-then-jump race)", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
const seekCall = mockInvoke.mock.calls.find((c) => c[0] === "player_seek");
expect(seekCall).toBeUndefined();
});
it("tells Rust to stop the remote session with the current item and position", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
setRemoteSessionPlaying();
mockInvoke.mockResolvedValue(undefined);
await playbackMode.transferToLocal();
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_local", {
currentItemId: "item-abc",
positionTicks: REMOTE_POSITION_TICKS,
});
const state = get(playbackMode);
expect(state.mode).toBe("local");
expect(state.remoteSessionId).toBeNull();
expect(state.isTransferring).toBe(false);
});
it("switches to local without playing media when remote has nothing playing", async () => {
const { playbackMode } = await import("./playbackMode");
playbackMode.setMode("remote", "session-123");
currentSelectedSession = { id: "session-123", nowPlayingItem: null, playState: null };
await playbackMode.transferToLocal();
expect(mockInvoke).not.toHaveBeenCalledWith("player_play_tracks", expect.anything());
const state = get(playbackMode);
expect(state.mode).toBe("local");
expect(state.remoteSessionId).toBeNull();
});
});
describe("clearError", () => {
it("should clear transfer error", async () => {
const { playbackMode } = await import("./playbackMode");

View File

@ -192,10 +192,14 @@ function createPlaybackModeStore() {
// Use player_play_tracks - backend fetches all metadata from single ID
const repositoryHandle = repository.getHandle();
// Pass the resume position so the backend seeks at load time. Doing the
// seek here (rather than a delayed playerSeek) avoids the race where the
// media isn't loaded yet and the seek is lost, restarting from 0.
await commands.playerPlayTracks(repositoryHandle, {
trackIds: [itemId],
startIndex: 0,
shuffle: false,
startPosition: positionSeconds,
context: {
type: "search",
searchQuery: "",
@ -204,16 +208,6 @@ function createPlaybackModeStore() {
if (aborted) return;
// Wait briefly for media to load
await new Promise((resolve) => setTimeout(resolve, 500));
// Seek to position if not at the very start
if (positionSeconds > 0.5) {
await commands.playerSeek(positionSeconds);
}
if (aborted) return;
// Let Rust handle stopping remote playback
await commands.playbackModeTransferToLocal(itemId, positionTicks);

View File

@ -7,8 +7,7 @@
// TRACES: UR-005, UR-015 | DR-005, DR-020
import { writable, derived, get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { commands, events } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@ -51,12 +50,12 @@ function createQueueStore() {
// Initial sync from backend
await syncFromRust();
// Listen for queue changed events
unlisten = await listen<QueueChangedEvent>("player-event", (event) => {
if ((event.payload as any).type === "queue_changed") {
const queueEvent = event.payload as any;
// Listen for queue changed events (generated tauri-specta event)
unlisten = await events.playerStatusEvent.listen((event) => {
if (event.payload.type === "queue_changed") {
const queueEvent = event.payload;
set({
items: queueEvent.items,
items: queueEvent.items as unknown as MediaItem[],
currentIndex: queueEvent.current_index,
shuffle: queueEvent.shuffle,
repeat: queueEvent.repeat,

View File

@ -2,8 +2,7 @@
// TRACES: UR-010 | DR-037
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { commands, events } from "$lib/api/bindings";
import type { Session } from "$lib/api/types";
interface SessionsState {
@ -14,11 +13,6 @@ interface SessionsState {
lastUpdated: Date | null;
}
interface PlayerStatusEvent {
type: string;
sessions?: Session[];
}
function createSessionsStore() {
const initialState: SessionsState = {
sessions: [],
@ -30,16 +24,17 @@ function createSessionsStore() {
const { subscribe, update } = writable<SessionsState>(initialState);
// Listen for session updates from Rust backend
listen<PlayerStatusEvent>("player-event", (event) => {
if (event.payload.type === "sessions_updated" && event.payload.sessions) {
console.log(`[Sessions] Received ${event.payload.sessions.length} sessions from backend`);
event.payload.sessions.forEach((s, i) => {
// Listen for session updates from Rust backend (generated tauri-specta event)
events.playerStatusEvent.listen((event) => {
if (event.payload.type === "sessions_updated") {
const sessions = event.payload.sessions as unknown as Session[];
console.log(`[Sessions] Received ${sessions.length} sessions from backend`);
sessions.forEach((s, i) => {
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
});
update((s) => ({
...s,
sessions: event.payload.sessions!,
sessions,
lastUpdated: new Date(),
error: null,
}));

View File

@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { selectDiverseGenres, sampleAcross } from "./genreDiversity";
const g = (...names: string[]) => names.map(name => ({ name }));
describe("sampleAcross", () => {
it("returns input unchanged when at or under the count", () => {
expect(sampleAcross([1, 2, 3], 5)).toEqual([1, 2, 3]);
});
it("spreads the sample across the whole list rather than taking a prefix", () => {
// 26 letters, want ~7: a prefix would be AG; striding spans A→Z.
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const picked = sampleAcross(letters, 7);
expect(picked[0]).toBe("A");
// Reaches deep into the alphabet, not stuck near the front.
expect(picked[picked.length - 1] >= "S").toBe(true);
expect(picked.length).toBeLessThanOrEqual(7);
});
it("never exceeds the requested count", () => {
const items = Array.from({ length: 100 }, (_, i) => i);
expect(sampleAcross(items, 8).length).toBeLessThanOrEqual(8);
});
});
describe("selectDiverseGenres", () => {
it("returns input unchanged when at or under the limit", () => {
const input = g("Rock", "Jazz");
expect(selectDiverseGenres(input, 5)).toEqual(input);
});
it("seeds with the most populous genre (first in input)", () => {
const out = selectDiverseGenres(g("Rock", "Jazz", "Hip Hop"), 1);
expect(out.map(x => x.name)).toEqual(["Rock"]);
});
it("spreads the family instead of stacking near-synonyms", () => {
// Count ranking puts all the Rock variants first; a naive top-N would
// return four flavours of Rock. Diversity should pull in distinct genres.
const input = g(
"Rock",
"Hard Rock",
"Classic Rock",
"Pop Rock",
"Jazz",
"Hip Hop",
"Classical"
);
const names = selectDiverseGenres(input, 4).map(x => x.name);
expect(names[0]).toBe("Rock"); // seeded by count
expect(names).toContain("Jazz");
expect(names).toContain("Hip Hop");
expect(names).toContain("Classical");
// Only the seed represents the Rock cluster.
expect(names.filter(n => n.includes("Rock"))).toEqual(["Rock"]);
});
it("preserves count order as the tie-breaker among equally-distinct genres", () => {
// All four are mutually distinct (no shared tokens), so every pick after
// the seed is a distance tie and should follow input (count) order.
const input = g("Rock", "Jazz", "Blues", "Folk");
expect(selectDiverseGenres(input, 3).map(x => x.name)).toEqual([
"Rock",
"Jazz",
"Blues",
]);
});
it("is case- and separator-insensitive when comparing", () => {
const input = g("Hip Hop", "hip-hop", "Reggae");
// "Hip Hop" and "hip-hop" share all tokens → the second is redundant.
expect(selectDiverseGenres(input, 2).map(x => x.name)).toEqual([
"Hip Hop",
"Reggae",
]);
});
});

View File

@ -0,0 +1,95 @@
// Selecting a *diverse* set of genres for the music landing page.
//
// The naive approach ("keep the genres with the most albums") tends to surface
// a cluster of near-synonyms — "Rock", "Hard Rock", "Classic Rock", "Pop Rock"
// — because big umbrella genres and their sub-genres are all populous. The
// result reads as one genre repeated, not a tour of the library.
//
// Instead we pick greedily for *spread*: start from the most populous genre,
// then repeatedly add whichever remaining genre is least similar to everything
// already chosen (a max-min / farthest-point selection). Similarity is word-
// token overlap (Jaccard), so "Hard Rock" stays close to "Rock" but far from
// "Jazz" or "Hip Hop". Count still acts as a gentle tie-breaker so we don't
// promote a one-album novelty genre over a healthy distinct one.
/** Anything with a name and a relative weight (album count) we can rank by. */
export interface DiversityCandidate {
name: string;
}
/**
* Sample up to `count` items at an even stride across `items`. Genre lists come
* back alphabetical, so taking the first N would only ever surface A-genres;
* striding spreads the sample AZ. Always includes the first item.
*/
export function sampleAcross<T>(items: T[], count: number): T[] {
if (items.length <= count) return items.slice();
const stride = Math.max(1, Math.floor(items.length / count));
return items.filter((_, i) => i % stride === 0).slice(0, count);
}
/** Split a genre name into a set of lowercased word tokens. */
function tokenize(name: string): Set<string> {
return new Set(
name
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
);
}
/** Jaccard similarity of two token sets: |A∩B| / |AB|, in [0, 1]. */
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 && b.size === 0) return 1;
let intersection = 0;
for (const t of a) if (b.has(t)) intersection++;
const union = a.size + b.size - intersection;
return union === 0 ? 0 : intersection / union;
}
/**
* Pick up to `limit` genres that are textually distinct from one another,
* preferring more populous genres. Input order is treated as the count ranking
* (most albums first); ties in distance fall back to that order.
*/
export function selectDiverseGenres<T extends DiversityCandidate>(
candidates: T[],
limit: number
): T[] {
if (candidates.length <= limit) return candidates.slice();
const tokens = candidates.map(c => tokenize(c.name));
const chosen: number[] = [];
const remaining = new Set(candidates.map((_, i) => i));
// Seed with the most populous genre (candidates[0]).
chosen.push(0);
remaining.delete(0);
while (chosen.length < limit && remaining.size > 0) {
let best = -1;
// The best candidate is the one *least* similar to its most-similar chosen
// member. We track that max-similarity and minimise it: a genre is only
// "diverse" if it resembles *nothing* already chosen, so looking at the
// single nearest neighbour (as plain farthest-point does) isn't enough —
// "Hard Rock" must stay close to "Rock" even after unrelated "Jazz" is in.
let bestMaxSim = Infinity;
for (const i of remaining) {
let maxSim = 0;
for (const c of chosen) {
const sim = jaccard(tokens[i], tokens[c]);
if (sim > maxSim) maxSim = sim;
}
// Lower max-similarity wins; on a tie keep the earlier (more populous)
// one, guaranteed because `remaining` iterates in insertion order.
if (maxSim < bestMaxSim) {
bestMaxSim = maxSim;
best = i;
}
}
chosen.push(best);
remaining.delete(best);
}
return chosen.map(i => candidates[i]);
}

View File

@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { navigateBack } from "./navigation";
const goto = vi.fn();
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args),
}));
describe("navigateBack", () => {
beforeEach(() => {
goto.mockClear();
});
it("pops real history when there is in-app history to go back to", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(3);
navigateBack("/library");
expect(back).toHaveBeenCalledOnce();
expect(goto).not.toHaveBeenCalled();
});
it("falls back to the given path on a fresh deep-link (no history)", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(1);
navigateBack("/library/music");
expect(goto).toHaveBeenCalledWith("/library/music");
expect(back).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,33 @@
import { goto } from "$app/navigation";
/**
* Navigate "back" using real browser/Android history when possible, falling
* back to an explicit path otherwise.
*
* Hardcoded `goto(backPath)` always sends the user to a fixed screen, which
* loses track of where they actually came from (e.g. reaching the genres list
* from different entry points). Preferring `history.back()` keeps the back
* affordance consistent with the platform back gesture and the browser/Android
* hardware back button.
*
* We only use history when there is somewhere to go back to *within the app*.
* On a fresh deep-link (history length 1, or an external referrer) we fall back
* to `fallbackPath` so the user never gets stranded or bounced out of the app.
*/
export function navigateBack(fallbackPath: string): void {
if (canGoBack()) {
history.back();
} else {
goto(fallbackPath);
}
}
/**
* True when there is in-app history to pop. `history.length > 1` means the user
* navigated here from another page in this session rather than landing here
* directly (deep link, refresh, or first load).
*/
function canGoBack(): boolean {
if (typeof history === "undefined") return false;
return history.length > 1;
}

View File

@ -17,7 +17,10 @@
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomNav from "$lib/components/BottomNav.svelte";
import { isInitialized, pendingSyncCount, isAndroid, shuffle, repeat, hasNext, hasPrevious, showSleepTimerModal } from "$lib/stores/appState";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
// Shuffle/repeat/next/previous come from the event-driven queue store, the
// single source of truth (updated instantly on queue_changed).
import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue";
let { children } = $props();

View File

@ -4,11 +4,13 @@
import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated } from "$lib/stores/auth";
import { home } from "$lib/stores/home";
import { library, libraries } from "$lib/stores/library";
import { isServerReachable } from "$lib/stores/connectivity";
import { currentMedia } from "$lib/stores/player";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import type { MediaItem, Library } from "$lib/api/types";
// Track if we've done an initial load (plain variable, not reactive)
let hasLoadedOnce = false;
@ -34,6 +36,9 @@
if ($isAuthenticated) {
await home.loadHomeSections();
if ($libraries.length === 0) {
await library.loadLibraries();
}
hasLoadedOnce = true;
}
});
@ -68,6 +73,31 @@
}
}
// Playlist libraries live inside the Music landing page, not as top-level shortcuts.
const shortcutLibraries = $derived(
$libraries.filter((lib) => lib.collectionType !== "playlists")
);
function handleLibraryClick(lib: Library) {
// Mirror /library routing: dedicated landing pages need currentLibrary set.
library.setCurrentLibrary(lib);
switch (lib.collectionType) {
case "music":
goto("/library/music");
break;
case "tvshows":
goto("/library/tv");
break;
case "movies":
goto("/library/movies");
break;
default:
library.clearGenres();
goto("/library");
break;
}
}
const heroItems = $derived($home.heroItems);
const resumeItems = $derived($home.resumeItems.filter(
i => i.type === "Movie" || i.type === "Episode"
@ -92,6 +122,24 @@
<HeroBanner items={heroItems} />
{/if}
<!-- Your Libraries: quick jump into each dedicated landing page -->
{#if shortcutLibraries.length > 0}
<div>
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
<div class="flex gap-4 overflow-x-auto px-4 pb-2">
{#each shortcutLibraries as lib (lib.id)}
<div class="flex-shrink-0">
<MediaCard
item={lib}
size="medium"
onclick={() => handleLibraryClick(lib)}
/>
</div>
{/each}
</div>
</div>
{/if}
<!-- Next Movie -->
{#if resumeMovies.length > 0}
<Carousel

View File

@ -7,6 +7,7 @@
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
@ -23,16 +24,15 @@
let showFullPlayer = $state(false);
let showOverflowMenu = $state(false);
let showSleepTimerModal = $state(false);
let shuffle = $state(false);
let repeat = $state<"off" | "all" | "one">("off");
let hasNext = $state(false);
let hasPrevious = $state(false);
// Queue status (shuffle, repeat, hasNext, hasPrevious) is event-driven via the
// queue store, which listens for queue_changed events from the backend. This
// updates instantly when toggling shuffle/repeat (no polling lag).
const shuffle = $derived($isShuffle);
const repeat = $derived($repeatMode);
const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore);
let isAndroid = $state(false);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let failedAttempts = 0;
const MAX_SILENT_FAILURES = 3; // Don't log errors for first 3 attempts
onMount(() => {
// Detect platform
try {
@ -42,17 +42,7 @@
console.error("Platform detection failed:", err);
}
// Poll for queue status (shuffle, repeat, hasNext, hasPrevious)
// Position/duration come from player events (pushed every 250ms)
// Add a small delay before starting polling to ensure player is ready on Android
const timeoutId = setTimeout(() => {
updateQueueStatus(); // Initial update
pollInterval = setInterval(updateQueueStatus, 1000);
}, 100);
return () => {
clearTimeout(timeoutId);
if (pollInterval) clearInterval(pollInterval);
scrollGuard.cleanup();
};
});
@ -64,26 +54,6 @@
}
});
async function updateQueueStatus() {
try {
const queue = await commands.playerGetQueue();
// Reset failure counter on success
failedAttempts = 0;
hasNext = queue.hasNext;
hasPrevious = queue.hasPrevious;
shuffle = queue.shuffle;
repeat = queue.repeat as "off" | "all" | "one";
} catch (e) {
failedAttempts++;
// Only log errors after initial attempts (player may still be initializing)
if (failedAttempts > MAX_SILENT_FAILURES) {
console.error("[Queue Status] Error:", e);
}
}
}
async function handleLogout() {
await auth.logout();
library.reset();

View File

@ -2,6 +2,7 @@
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
@ -23,6 +24,8 @@
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
import GenreTags from "$lib/components/library/GenreTags.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
interface SeasonData {
season: MediaItem;
@ -281,6 +284,13 @@
// Navigate to series page without the episode param
goto(`/library/${itemId}`);
}
function goBack() {
// Prefer real history so the user returns to wherever they came from
// (libraries grid, a list page, a parent album/artist, search, etc.),
// falling back to the libraries overview on a fresh deep-link.
navigateBack("/library");
}
</script>
<div class="relative">
@ -315,6 +325,9 @@
{:else if item}
<!-- Person Detail View - shown for Person items -->
{#if item.type === "Person"}
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.type === "Series" && focusedEpisode}
@ -326,8 +339,13 @@
/>
{:else}
<div class="space-y-8">
<!-- Back navigation -->
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<!-- Header with item info -->
<div class="flex gap-6 pt-4">
<div class="flex gap-6">
<!-- Poster -->
<div class="flex-shrink-0 w-48">
{#if item.primaryImageTag}
@ -362,10 +380,17 @@
{#if item.parentIndexNumber && item.indexNumber}, {/if}
{#if item.indexNumber}Episode {item.indexNumber}{/if}
</p>
{:else if item.productionYear || item.artists?.length}
{:else if item.artistItems?.length || item.artists?.length}
<p class="text-lg text-gray-400 mt-1">
{item.artists?.join(", ") || item.productionYear}
<ArtistLinks
artistItems={item.artistItems}
artists={item.artists}
linkClass="text-lg text-[var(--color-jellyfin)] hover:underline"
textClass="text-gray-400"
/>
</p>
{:else if item.productionYear}
<p class="text-lg text-gray-400 mt-1">{item.productionYear}</p>
{/if}
</div>

View File

@ -2,6 +2,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { movies } from "$lib/stores/movies";
import { isServerReachable } from "$lib/stores/connectivity";
@ -85,7 +86,7 @@
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
<button
onclick={() => goto("/library")}
onclick={() => navigateBack("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"

View File

@ -2,6 +2,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { auth } from "$lib/stores/auth";
import { currentLibrary } from "$lib/stores/library";
import { music } from "$lib/stores/music";
@ -86,6 +87,7 @@
const newlyAdded = $derived($music.newlyAdded);
const playlists = $derived($music.playlists);
const rediscover = $derived($music.rediscover);
const genreRows = $derived($music.genreRows);
const isLoading = $derived($music.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
@ -106,7 +108,7 @@
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">Music</h1>
<button
onclick={() => goto("/library")}
onclick={() => navigateBack("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
@ -160,6 +162,16 @@
/>
{/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}
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
{/if}

View File

@ -2,6 +2,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { tv } from "$lib/stores/tv";
import { isServerReachable } from "$lib/stores/connectivity";
@ -92,7 +93,7 @@
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
<button
onclick={() => goto("/library")}
onclick={() => navigateBack("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"

View File

@ -8,7 +8,7 @@
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
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 { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player";
import { get } from "svelte/store";
@ -47,10 +47,12 @@
const position = $derived($playbackPosition);
const duration = $derived($playbackDuration);
let isPlaying = $state(false);
let shuffle = $state(false);
let repeat = $state<"off" | "all" | "one">("off");
let hasNext = $state(false);
let hasPrevious = $state(false);
// Shuffle/repeat/next/previous are event-driven via the queue store (instant
// update on queue_changed), not polled.
const shuffle = $derived($isShuffle);
const repeat = $derived($repeatMode);
const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore);
let currentMedia = $state<MediaItem | null>(null);
let streamUrl = $state<string | null>(null);
let mediaSourceId = $state<string | null>(null);
@ -133,14 +135,7 @@
isVideo = item.type === "Movie" || item.type === "Episode";
isPlaying = true;
loading = false;
// Sync queue status
try {
const queueStatus = await commands.playerGetQueue();
hasNext = queueStatus.hasNext;
hasPrevious = queueStatus.hasPrevious;
} catch (e) {
// Ignore - queue status will update via polling
}
// hasNext/hasPrevious come from the event-driven queue store.
// Fetch next episode for video skip button
if (isVideo) {
fetchNextEpisode(item);
@ -446,13 +441,7 @@
isPlaying = status.state.kind === "playing";
// Note: position/duration are now derived from player store (updated by events)
}
shuffle = status.shuffle;
repeat = status.repeat;
// Update queue status
const queue = await commands.playerGetQueue();
hasNext = queue.hasNext;
hasPrevious = queue.hasPrevious;
// shuffle/repeat/hasNext/hasPrevious come from the queue store (event-driven).
} catch (e) {
// Ignore polling errors
}