fix: quality badge accuracy and queue drag-to-reorder

Quality Badge Fix:
- Extended BackendQueueTrack with hires, bit_depth, sample_rate fields
- Updated all queue track builder functions to pass quality data
- Fixed quality badge showing CD instead of Hi-Res for playlist tracks
- Extended session persistence to save/restore quality info

Queue Drag-to-Reorder:
- Added move_track function to Rust QueueManager
- Added move_queue_track Tauri command
- Implemented HTML5 drag-and-drop in QueuePanel
- Visual feedback with drag-over indicator and dragging opacity
- Disabled drag when search filter is active
- Prevented text selection during drag operations
This commit is contained in:
vicrodh committed 2026-01-11 19:28:34 -06:00
1 parent 24c8ee5cad
commit 70f03a1003
9 files changed
+256 -18

No files matched your search

+7
View File
@@ -52,6 +52,13 @@ pub fn remove_from_queue(index: usize, state: State<'_, AppState>) -> Result<Opt
Ok(state.queue.remove_track(index))
}
/// Move a track from one position to another in the queue
#[tauri::command]
pub fn move_queue_track(from_index: usize, to_index: usize, state: State<'_, AppState>) -> Result<bool, String> {
log::info!("Command: move_queue_track - from {} to {}", from_index, to_index);
Ok(state.queue.move_track(from_index, to_index))
}
/// Get current track in queue
#[tauri::command]
pub fn get_current_queue_track(state: State<'_, AppState>) -> Result<Option<QueueTrack>, String> {
+1
View File
@@ -245,6 +245,7 @@ pub fn run() {
commands::set_queue,
commands::clear_queue,
commands::remove_from_queue,
commands::move_queue_track,
commands::get_current_queue_track,
commands::peek_next_track,
commands::next_track,
+32
View File
@@ -19,6 +19,10 @@ pub struct QueueTrack {
pub album: String,
pub duration_secs: u64,
pub artwork_url: Option<String>,
#[serde(default)]
pub hires: bool,
pub bit_depth: Option<u32>,
pub sample_rate: Option<f64>,
}
/// Repeat mode options
@@ -193,6 +197,34 @@ impl QueueManager {
Some(removed)
}
/// Move a track from one position to another
pub fn move_track(&self, from_index: usize, to_index: usize) -> bool {
let mut state = self.state.lock().unwrap();
if from_index >= state.tracks.len() || to_index >= state.tracks.len() || from_index == to_index {
return false;
}
let track = state.tracks.remove(from_index);
state.tracks.insert(to_index, track);
// Adjust current index if needed
if let Some(curr_idx) = state.current_index {
if from_index == curr_idx {
// The current track was moved
state.current_index = Some(to_index);
} else if from_index < curr_idx && to_index >= curr_idx {
// Track moved from before current to at/after current
state.current_index = Some(curr_idx - 1);
} else if from_index > curr_idx && to_index <= curr_idx {
// Track moved from after current to at/before current
state.current_index = Some(curr_idx + 1);
}
}
Self::regenerate_shuffle_order_internal(&mut state);
true
}
/// Get current track
pub fn current_track(&self) -> Option<QueueTrack> {
let state = self.state.lock().unwrap();
+37 -4
View File
@@ -12,6 +12,10 @@ pub struct PersistedQueueTrack {
pub album: String,
pub duration_secs: u64,
pub artwork_url: Option<String>,
#[serde(default)]
pub hires: bool,
pub bit_depth: Option<u32>,
pub sample_rate: Option<f64>,
}
/// Represents the full persisted session state
@@ -84,7 +88,10 @@ impl SessionStore {
artist TEXT NOT NULL,
album TEXT NOT NULL,
duration_secs INTEGER NOT NULL,
artwork_url TEXT
artwork_url TEXT,
hires INTEGER NOT NULL DEFAULT 0,
bit_depth INTEGER,
sample_rate REAL
);
-- Insert default row if not exists
@@ -93,6 +100,26 @@ impl SessionStore {
"
).map_err(|e| format!("Failed to create session tables: {}", e))?;
// Migrate existing queue_tracks table to add new columns if they don't exist
// SQLite doesn't support IF NOT EXISTS for ALTER TABLE, so we check the schema
let has_hires: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'hires'",
[],
|row| row.get(0),
)
.unwrap_or(0) > 0;
if !has_hires {
let _ = conn.execute_batch(
"
ALTER TABLE queue_tracks ADD COLUMN hires INTEGER NOT NULL DEFAULT 0;
ALTER TABLE queue_tracks ADD COLUMN bit_depth INTEGER;
ALTER TABLE queue_tracks ADD COLUMN sample_rate REAL;
"
);
}
Ok(Self { conn })
}
@@ -116,8 +143,8 @@ impl SessionStore {
// Insert queue tracks
for (pos, track) in session.queue_tracks.iter().enumerate() {
if let Err(e) = self.conn.execute(
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
pos as i64,
track.id as i64,
@@ -126,6 +153,9 @@ impl SessionStore {
track.album,
track.duration_secs as i64,
track.artwork_url,
track.hires as i64,
track.bit_depth.map(|v| v as i64),
track.sample_rate,
],
) {
let _ = self.conn.execute("ROLLBACK", []);
@@ -187,7 +217,7 @@ impl SessionStore {
// Load queue tracks
let mut stmt = self.conn
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url FROM queue_tracks ORDER BY position")
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate FROM queue_tracks ORDER BY position")
.map_err(|e| format!("Failed to prepare queue query: {}", e))?;
let tracks: Vec<PersistedQueueTrack> = stmt
@@ -199,6 +229,9 @@ impl SessionStore {
album: row.get(3)?,
duration_secs: row.get::<_, i64>(4)? as u64,
artwork_url: row.get(5)?,
hires: row.get::<_, i64>(6).unwrap_or(0) != 0,
bit_depth: row.get::<_, Option<i64>>(7)?.map(|v| v as u32),
sample_rate: row.get(8)?,
})
})
.map_err(|e| format!("Failed to query queue tracks: {}", e))?
+93 -3
View File
@@ -18,6 +18,7 @@
onPlayTrack?: (trackId: string) => void;
onClearQueue?: () => void;
onSaveAsPlaylist?: () => void;
onReorderTrack?: (fromIndex: number, toIndex: number) => void;
}
let {
@@ -27,12 +28,17 @@
upcomingTracks,
onPlayTrack,
onClearQueue,
onSaveAsPlaylist
onSaveAsPlaylist,
onReorderTrack
}: Props = $props();
let hoveredTrack = $state<string | null>(null);
let searchQuery = $state('');
// Drag state
let draggedIndex = $state<number | null>(null);
let dragOverIndex = $state<number | null>(null);
// Filter tracks based on search query (searches entire queue)
const filteredTracks = $derived.by(() => {
if (!searchQuery.trim()) return upcomingTracks;
@@ -42,6 +48,62 @@
track.artist.toLowerCase().includes(query)
);
});
// Disable drag when search is active (can't reorder filtered list)
const canDrag = $derived(!searchQuery.trim());
function handleDragStart(e: DragEvent, index: number) {
if (!canDrag) {
e.preventDefault();
return;
}
draggedIndex = index;
// Set drag image and data
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}
}
function handleDragOver(e: DragEvent, index: number) {
if (!canDrag || draggedIndex === null) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
dragOverIndex = index;
}
function handleDragLeave() {
dragOverIndex = null;
}
function handleDrop(e: DragEvent, toIndex: number) {
e.preventDefault();
if (!canDrag || draggedIndex === null || draggedIndex === toIndex) {
draggedIndex = null;
dragOverIndex = null;
return;
}
onReorderTrack?.(draggedIndex, toIndex);
draggedIndex = null;
dragOverIndex = null;
}
function handleDragEnd() {
draggedIndex = null;
dragOverIndex = null;
}
// Handle click only on non-drag-handle area
function handleTrackClick(e: MouseEvent, trackId: string) {
const target = e.target as HTMLElement;
// Don't trigger play if clicking on drag handle
if (target.closest('.drag-handle')) {
return;
}
onPlayTrack?.(trackId);
}
</script>
{#if isOpen}
@@ -104,15 +166,23 @@
<div
class="queue-track"
class:hovered={hoveredTrack === track.id}
class:dragging={draggedIndex === originalIndex}
class:drag-over={dragOverIndex === originalIndex && draggedIndex !== originalIndex}
draggable={canDrag}
onmouseenter={() => (hoveredTrack = track.id)}
onmouseleave={() => (hoveredTrack = null)}
onclick={() => onPlayTrack?.(track.id)}
onclick={(e) => handleTrackClick(e, track.id)}
ondragstart={(e) => handleDragStart(e, originalIndex)}
ondragover={(e) => handleDragOver(e, originalIndex)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, originalIndex)}
ondragend={handleDragEnd}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onPlayTrack?.(track.id)}
>
<!-- Drag Handle -->
<div class="drag-handle" class:visible={hoveredTrack === track.id}>
<div class="drag-handle" class:visible={hoveredTrack === track.id && canDrag}>
<GripVertical size={14} />
</div>
@@ -382,10 +452,30 @@
background-color: var(--bg-tertiary);
}
.queue-track.dragging {
opacity: 0.5;
background-color: var(--bg-tertiary);
}
.queue-track.drag-over {
border-top: 2px solid var(--accent-primary);
margin-top: -2px;
}
.queue-track[draggable="true"] {
user-select: none;
-webkit-user-select: none;
}
.drag-handle {
color: #666666;
opacity: 0;
transition: opacity 150ms ease;
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
.drag-handle.visible {
+3
View File
@@ -14,6 +14,9 @@ export interface PersistedQueueTrack {
album: string;
duration_secs: number;
artwork_url: string | null;
hires?: boolean;
bit_depth?: number | null;
sample_rate?: number | null;
}
export interface PersistedSession {
+18 -4
View File
@@ -40,7 +40,10 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
artist: track.performer?.name || 'Unknown Artist',
album: track.album?.title || '',
duration_secs: track.duration,
artwork_url: artwork || null
artwork_url: artwork || null,
hires: track.hires_streamable ?? false,
bit_depth: track.maximum_bit_depth ?? null,
sample_rate: track.maximum_sampling_rate ?? null
};
}
@@ -51,7 +54,10 @@ export function buildQueueTrackFromAlbumTrack(track: Track, albumArtwork: string
artist: track.artist || albumArtist || 'Unknown Artist',
album: albumTitle || '',
duration_secs: track.durationSeconds,
artwork_url: albumArtwork || null
artwork_url: albumArtwork || null,
hires: track.hires ?? false,
bit_depth: track.bitDepth ?? null,
sample_rate: track.samplingRate ?? null
};
}
@@ -62,19 +68,27 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
artist: track.artist || 'Unknown Artist',
album: track.album || 'Playlist',
duration_secs: track.durationSeconds,
artwork_url: track.albumArt || null
artwork_url: track.albumArt || null,
hires: track.hires ?? false,
bit_depth: track.bitDepth ?? null,
sample_rate: track.samplingRate ?? null
};
}
export function buildQueueTrackFromLocalTrack(track: LocalLibraryTrack): BackendQueueTrack {
const artwork = track.artwork_path ? `asset://localhost/${encodeURIComponent(track.artwork_path)}` : null;
// Local tracks are hi-res if bit_depth > 16 or sample_rate > 44100
const isHires = Boolean((track.bit_depth && track.bit_depth > 16) || (track.sample_rate && track.sample_rate > 44100));
return {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
duration_secs: track.duration_secs,
artwork_url: artwork
artwork_url: artwork,
hires: isHires,
bit_depth: track.bit_depth ?? null,
sample_rate: track.sample_rate ?? null
};
}
+19
View File
@@ -23,6 +23,9 @@ export interface BackendQueueTrack {
album: string;
duration_secs: number;
artwork_url: string | null;
hires: boolean;
bit_depth: number | null;
sample_rate: number | null;
}
interface BackendQueueState {
@@ -294,6 +297,22 @@ export async function previousTrack(): Promise<BackendQueueTrack | null> {
}
}
/**
* Move a track from one position to another in the queue
*/
export async function moveQueueTrack(fromIndex: number, toIndex: number): Promise<boolean> {
try {
const success = await invoke<boolean>('move_queue_track', { fromIndex, toIndex });
if (success) {
await syncQueueState();
}
return success;
} catch (err) {
console.error('Failed to move queue track:', err);
return false;
}
}
// ============ Local Track Management ============
/**
+46 -7
View File
@@ -105,6 +105,7 @@
playQueueIndex,
nextTrack,
previousTrack,
moveQueueTrack,
setLocalTrackIds,
clearLocalTrackIds,
isLocalTrack,
@@ -368,7 +369,10 @@
artist: t.artist || selectedAlbum?.artist || 'Unknown Artist',
album: selectedAlbum?.title || '',
duration_secs: t.durationSeconds,
artwork_url: artwork || null
artwork_url: artwork || null,
hires: t.hires ?? false,
bit_depth: t.bitDepth ?? null,
sample_rate: t.samplingRate ?? null
}));
// Set the queue starting at the clicked track
@@ -484,6 +488,15 @@
// Reset queue ended flag when playing a new track
setQueueEnded(false);
// Determine quality string from track data
const quality = isLocal
? 'Local'
: track.bit_depth && track.sample_rate
? `${track.bit_depth}bit/${track.sample_rate}kHz`
: track.hires
? 'Hi-Res'
: 'CD Quality';
// Play track using unified service
await playTrack({
id: track.id,
@@ -492,7 +505,9 @@
album: track.album,
artwork: track.artwork_url || '',
duration: track.duration_secs,
quality: isLocal ? 'Local' : 'Hi-Res',
quality,
bitDepth: track.bit_depth ?? undefined,
samplingRate: track.sample_rate ?? undefined,
isLocal
}, { isLocal, showLoadingToast: false });
}
@@ -543,6 +558,14 @@
}
}
// Reorder tracks in the queue
async function handleQueueReorder(fromIndex: number, toIndex: number) {
const success = await moveQueueTrack(fromIndex, toIndex);
if (!success) {
showToast('Failed to reorder queue', 'error');
}
}
// Play all tracks from album (starting from first track)
async function handlePlayAllAlbum() {
if (!selectedAlbum?.tracks?.length) return;
@@ -581,7 +604,10 @@
artist: t.artist || selectedAlbum?.artist || 'Unknown Artist',
album: selectedAlbum?.title || '',
duration_secs: t.durationSeconds,
artwork_url: artwork || null
artwork_url: artwork || null,
hires: t.hires ?? false,
bit_depth: t.bitDepth ?? null,
sample_rate: t.samplingRate ?? null
});
}
showToast(`Playing ${selectedAlbum.tracks.length} tracks next`, 'success');
@@ -598,7 +624,10 @@
artist: t.artist || selectedAlbum?.artist || 'Unknown Artist',
album: selectedAlbum?.title || '',
duration_secs: t.durationSeconds,
artwork_url: artwork || null
artwork_url: artwork || null,
hires: t.hires ?? false,
bit_depth: t.bitDepth ?? null,
sample_rate: t.samplingRate ?? null
}));
const success = await addTracksToQueue(queueTracks);
@@ -735,9 +764,15 @@
function handleDisplayTrackPlay(track: DisplayTrack): void {
console.log('Playing display track:', track);
const quality = track.hires && track.bitDepth && track.samplingRate
// Determine quality string:
// - If we have exact bitDepth/samplingRate, show them
// - If hires flag is true but no exact values, show "Hi-Res"
// - Otherwise "CD Quality"
const quality = track.bitDepth && track.samplingRate
? `${track.bitDepth}bit/${track.samplingRate}kHz`
: 'CD Quality';
: track.hires
? 'Hi-Res'
: 'CD Quality';
// Fire-and-forget async call
playTrack({
@@ -841,7 +876,10 @@
artist: t.artist,
album: t.album,
duration_secs: t.duration_secs,
artwork_url: t.artwork_url
artwork_url: t.artwork_url,
hires: t.hires ?? false,
bit_depth: t.bit_depth ?? null,
sample_rate: t.sample_rate ?? null
}));
await setQueue(tracks, session.current_index ?? 0, true);
@@ -1404,6 +1442,7 @@
onPlayTrack={handleQueueTrackPlay}
onClearQueue={handleClearQueue}
onSaveAsPlaylist={() => showToast('Save as playlist coming soon', 'info')}
onReorderTrack={handleQueueReorder}
/>
<!-- Expanded Player -->