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:
1 parent
24c8ee5cad
commit
70f03a1003
9 files changed
+256
-18
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
Reference in new issue
Block a user