Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"opener:default",
"dialog:default"
]
Expand Down
66 changes: 59 additions & 7 deletions src-tauri/src/audio/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ use symphonia::default::{get_codecs, get_probe};

const MAX_DECODE_RETRIES: usize = 3;

// Seeking exactly at (or past) a track's total duration leaves symphonia's
// format reader with no packet to land on, so an end-of-track seek target
// is nudged just inside the track by this many seconds instead.
const END_OF_TRACK_SEEK_EPSILON: f64 = 0.0001;

/// Wraps a file with a correctly-reported byte length, unlike rodio 0.20's
/// internal `ReadSeekSource`, which hardcodes `byte_len()` to `None`. Without
/// a known byte length, symphonia's FLAC/format readers can't compute a seek
Expand Down Expand Up @@ -207,6 +212,20 @@ impl FileSource {
}
}

/// Nudges a `Time` back by `END_OF_TRACK_SEEK_EPSILON`, borrowing a whole
/// second (via `1.0 + frac`, not `1.0 - frac`) when the subtraction pushes
/// the fractional part negative — `Time::frac` must stay within `[0.0,
/// 1.0)` or symphonia's `TimeBase::calc_timestamp` panics.
fn time_just_before(duration: Time) -> Time {
let mut t = duration;
t.frac -= END_OF_TRACK_SEEK_EPSILON;
if t.frac < 0.0 {
t.seconds = t.seconds.saturating_sub(1);
t.frac += 1.0;
}
t
}

impl Iterator for FileSource {
type Item = i16;

Expand Down Expand Up @@ -259,13 +278,7 @@ impl Source for FileSource {
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);

let time: Time = if seek_beyond_end {
let mut t = self.total_duration.expect("checked by seek_beyond_end above");
t.frac -= 0.0001;
if t.frac < 0.0 {
t.seconds = t.seconds.saturating_sub(1);
t.frac = 1.0 - t.frac;
}
t
time_just_before(self.total_duration.expect("checked by seek_beyond_end above"))
} else {
pos.as_secs_f64().into()
};
Expand All @@ -290,3 +303,42 @@ impl Source for FileSource {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;

fn assert_valid_time(time: Time) {
assert!(
time.frac >= 0.0 && time.frac < 1.0,
"Time::frac must stay in [0.0, 1.0), got {}",
time.frac
);
}

#[test]
fn time_just_before_subtracts_the_epsilon_without_borrowing() {
let result = time_just_before(Time::new(10, 0.5));
assert_valid_time(result);
assert_eq!(result.seconds, 10);
assert!((result.frac - (0.5 - END_OF_TRACK_SEEK_EPSILON)).abs() < f64::EPSILON);
}

#[test]
fn time_just_before_borrows_a_second_when_frac_underflows() {
// A previous version computed `1.0 - t.frac` here on an already-negative
// `t.frac`, producing e.g. 1.00005 and panicking deep in symphonia's
// `TimeBase::calc_timestamp` ("Invalid range for Time fractional part").
let result = time_just_before(Time::new(5, 0.00005));
assert_valid_time(result);
assert_eq!(result.seconds, 4);
assert!((result.frac - 0.99995).abs() < 1e-9);
}

#[test]
fn time_just_before_saturates_seconds_at_zero_for_sub_second_tracks() {
let result = time_just_before(Time::new(0, 0.0));
assert_valid_time(result);
assert_eq!(result.seconds, 0);
}
}
22 changes: 19 additions & 3 deletions src-tauri/src/audio/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ pub(super) fn run_engine(
/// re-resamples/re-channel-converts at each source boundary automatically —
/// so a queue of mixed sample rates/bit depths needs no manual handling
/// here, just keeping the next source appended in time.
///
/// Running past the last track doesn't clear "now playing": it loops the
/// queue back to the first track and leaves playback paused there, so the
/// album stays visibly cued up rather than disappearing.
fn poll_queue_advance(state: &mut EngineState, mpris: &Mpris) {
let Some(sink) = &state.sink else { return };
let current_len = sink.len();
Expand All @@ -117,13 +121,25 @@ fn poll_queue_advance(state: &mut EngineState, mpris: &Mpris) {
append_track(sink, next, state.eq.clone());
}
}
state.last_sink_len = sink.len();
record_current_play(state);
persist_session(state);
return;
}

if let Some(queue) = &mut state.queue {
queue.reset_to_start();
start_playback_from_queue(state, mpris, false, None);
persist_session(state);
} else {
state.queue = None;
mpris.set_playback(MediaPlayback::Stopped);
// state.sink and state.queue are always set/cleared together (Stop,
// SetQueue, and start_playback_from_queue all pair them), so this
// branch should be unreachable while state.sink is Some — kept only
// to keep last_sink_len in sync if that invariant is ever broken.
// No MPRIS update needed here: whatever paired teardown cleared the
// queue already sent its own Stopped notice.
state.last_sink_len = sink.len();
}
state.last_sink_len = sink.len();
}

fn handle_command(state: &mut EngineState, cmd: PlayerCommand, mpris: &Mpris) {
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/audio/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ impl Queue {
self.current()
}

/// Moves back to the first track, e.g. so playback can loop back to the
/// start (paused) once the queue naturally runs out at the end.
pub fn reset_to_start(&mut self) {
self.index = 0;
}

pub fn move_to_previous(&mut self) -> Option<&TrackInfo> {
if self.index == 0 {
return None;
Expand Down Expand Up @@ -111,6 +117,17 @@ mod tests {
assert_eq!(q.index(), 0, "index should stay at 0, not underflow");
}

#[test]
fn reset_to_start_returns_to_the_first_track() {
let mut q = queue_of(&[1, 2, 3], 0);
q.advance();
q.advance();
assert!(q.advance().is_none(), "should be past the last track");
q.reset_to_start();
assert_eq!(q.index(), 0);
assert_eq!(q.current().unwrap().track_id, 1);
}

#[test]
fn track_ids_preserves_order() {
let q = queue_of(&[5, 3, 9], 0);
Expand Down
206 changes: 206 additions & 0 deletions src/lib/components/common/Dropdown.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<script lang="ts" generics="Value extends string">
import { tick } from "svelte";
import { ChevronDown } from "@lucide/svelte";

type Option = { value: Value; label: string };

let {
options,
value,
onChange,
ariaLabel,
}: {
options: Option[];
value: Value;
onChange: (value: Value) => void;
ariaLabel: string;
} = $props();

let open = $state(false);
let focusedOptionIndex = $state(0);
let optionElements: (HTMLButtonElement | null)[] = [];
let triggerElement = $state<HTMLButtonElement | null>(null);

const selectedLabel = $derived(options.find((option) => option.value === value)?.label ?? "");

function select(option: Option) {
onChange(option.value);
open = false;
triggerElement?.focus();
}

async function openList() {
open = true;
focusedOptionIndex = Math.max(0, options.findIndex((option) => option.value === value));
await tick();
optionElements[focusedOptionIndex]?.focus();
}

function handleListKeydown(event: KeyboardEvent) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusedOptionIndex = (focusedOptionIndex + 1) % options.length;
optionElements[focusedOptionIndex]?.focus();
break;
case "ArrowUp":
event.preventDefault();
focusedOptionIndex = (focusedOptionIndex - 1 + options.length) % options.length;
optionElements[focusedOptionIndex]?.focus();
break;
case "Home":
event.preventDefault();
focusedOptionIndex = 0;
optionElements[0]?.focus();
break;
case "End":
event.preventDefault();
focusedOptionIndex = options.length - 1;
optionElements[options.length - 1]?.focus();
break;
case "Escape":
open = false;
triggerElement?.focus();
break;
}
}

function handleButtonKeydown(event: KeyboardEvent) {
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !open) {
event.preventDefault();
openList();
}
}

function closeOnOutsideClick(node: HTMLElement) {
function onMouseDown(event: MouseEvent) {
if (!node.contains(event.target as Node)) open = false;
}
document.addEventListener("mousedown", onMouseDown);
return {
destroy() {
document.removeEventListener("mousedown", onMouseDown);
},
};
}
</script>

<div
class="dropdown"
use:closeOnOutsideClick
onfocusout={(event) => {
if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node | null)) {
open = false;
}
}}
>
<button
bind:this={triggerElement}
type="button"
class="dropdown-trigger"
class:active={open}
onclick={() => (open ? (open = false) : openList())}
onkeydown={handleButtonKeydown}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
>
<span class="dropdown-value">{selectedLabel}</span>
<ChevronDown size={14} class="dropdown-chevron" />
</button>

{#if open}
<div
class="dropdown-popover"
role="listbox"
aria-label={ariaLabel}
tabindex="-1"
onkeydown={handleListKeydown}
>
{#each options as option, i (option.value)}
<button
type="button"
role="option"
aria-selected={option.value === value}
tabindex="-1"
bind:this={optionElements[i]}
class="dropdown-option"
class:selected={option.value === value}
onclick={() => select(option)}
>
{option.label}
</button>
{/each}
</div>
{/if}
</div>

<style>
.dropdown {
position: relative;
}

.dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5em;
background-color: var(--bg-hover);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0.4em 0.6em;
}

.dropdown-trigger:hover,
.dropdown-trigger.active {
border-color: var(--accent);
}

.dropdown-value {
white-space: nowrap;
}

.dropdown-trigger :global(.dropdown-chevron) {
color: var(--text-tertiary);
flex-shrink: 0;
}

.dropdown-popover {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
z-index: 101;
display: flex;
flex-direction: column;
gap: 1px;
}

.dropdown-option {
display: block;
width: 100%;
text-align: left;
padding: 0.4em 0.6em;
font-size: inherit;
font-family: inherit;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}

.dropdown-option:hover,
.dropdown-option.selected {
background: var(--bg-hover);
color: var(--text-primary);
}
</style>
Loading
Loading