feat: Replay Gain support — songToTrack() + audio_update_replay_gain

Integrates PR #9 by @trbn1: replay gain was not applying because track
objects were built manually without replayGainTrackDb/replayGainAlbumDb/
replayGainPeak fields. All track construction sites now use songToTrack().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-30 22:07:46 +02:00
17 changed files with 359 additions and 475 deletions
+2 -12
View File
@@ -1,18 +1,17 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.18.0", "version": "1.21.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "psysonic", "name": "psysonic",
"version": "1.18.0", "version": "1.21.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
@@ -1494,15 +1493,6 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": { "node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de> # Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic pkgname=psysonic
pkgver=1.21.0 pkgver=1.22.0
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+16
View File
@@ -1623,6 +1623,22 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
} }
} }
#[tauri::command]
pub fn audio_update_replay_gain(
volume: f32,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
state: State<'_, AudioEngine>,
) {
let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let mut cur = state.current.lock().unwrap();
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
sink.set_volume(effective);
}
}
#[tauri::command] #[tauri::command]
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) { pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) {
state.eq_enabled.store(enabled, Ordering::Relaxed); state.eq_enabled.store(enabled, Ordering::Relaxed);
+1
View File
@@ -405,6 +405,7 @@ pub fn run() {
audio::audio_stop, audio::audio_stop,
audio::audio_seek, audio::audio_seek,
audio::audio_set_volume, audio::audio_set_volume,
audio::audio_update_replay_gain,
audio::audio_set_eq, audio::audio_set_eq,
audio::audio_preload, audio::audio_preload,
audio::audio_set_crossfade, audio::audio_set_crossfade,
+4 -12
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Play, Star } from 'lucide-react'; import { Play, Star } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic'; import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
@@ -83,15 +83,7 @@ export default function AlbumTrackList({
discs.get(disc)!.push(song); discs.get(disc)!.push(song);
}); });
const discNums = Array.from(discs.keys()).sort((a, b) => a - b); const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = discNums.length > 1; const isMultiDisc = discNums.length > 1;
const makeTrack = (song: SubsonicSong): Track => ({
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred, genre: song.genre,
});
return ( return (
<div className="tracklist"> <div className="tracklist">
@@ -123,7 +115,7 @@ export default function AlbumTrackList({
onContextMenu={e => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();
setContextMenuSongId(song.id); setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song'); onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}} }}
role="row" role="row"
onMouseDown={e => { onMouseDown={e => {
@@ -134,7 +126,7 @@ export default function AlbumTrackList({
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: makeTrack(song) }), label: song.title }, me.clientX, me.clientY); psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
} }
}; };
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
+12 -20
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react'; import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track } from '../store/playerStore'; import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -64,15 +64,11 @@ export default function ContextMenu() {
const startRadio = async (artistId: string, artistName: string) => { const startRadio = async (artistId: string, artistName: string) => {
try { try {
const similar = await getSimilarSongs2(artistId); const similar = await getSimilarSongs2(artistId);
if (similar.length > 0) { if (similar.length > 0) {
const top = await getTopSongs(artistName); const top = await getTopSongs(artistName);
const radioTracks = [...top, ...similar].map(s => ({ const radioTracks = [...top, ...similar].map(songToTrack);
id: s.id, title: s.title, artist: s.artist, album: s.album, playTrack(radioTracks[0], radioTracks);
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, }
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
playTrack(radioTracks[0], radioTracks);
}
} catch (e) { } catch (e) {
console.error('Failed to start radio', e); console.error('Failed to start radio', e);
} }
@@ -129,16 +125,12 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}> <div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')} <ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div> </div>
{type === 'album-song' && ( {type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => { <div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId); const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(s => ({ const tracks = albumData.songs.map(songToTrack);
id: s.id, title: s.title, artist: s.artist, album: s.album, enqueue(tracks);
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, })}>
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueue(tracks);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')} <ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</div> </div>
)} )}
+9 -13
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react'; import { Play, ListPlus } from 'lucide-react';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage'; import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum'; import { playAlbum } from '../utils/playAlbum';
@@ -151,18 +151,14 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
</button> </button>
<button <button
className="btn btn-surface" className="btn btn-surface"
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
try { try {
const albumData = await getAlbum(album.id); const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(s => ({ const tracks = albumData.songs.map(songToTrack);
id: s.id, title: s.title, artist: s.artist, album: s.album, usePlayerStore.getState().enqueue(tracks);
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, } catch (_) { }
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre, }}
}));
usePlayerStore.getState().enqueue(tracks);
} catch (_) { }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }} style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip={t('hero.enqueueTooltip')} data-tooltip={t('hero.enqueueTooltip')}
> >
+9 -9
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music } from 'lucide-react'; import { Search, Disc3, Users, Music } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void { function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
@@ -57,10 +57,10 @@ export default function LiveSearch() {
const flatItems = results ? [ const flatItems = results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => { ...(results.songs.map(s => ({ id: s.id, action: () => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre }); playTrack(songToTrack(s));
setOpen(false); setQuery(''); setOpen(false); setQuery('');
}}))), }}))),
] : []; ] : [];
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -175,10 +175,10 @@ export default function LiveSearch() {
const i = idx++; const i = idx++;
return ( return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`} <button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => { onClick={() => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre }); playTrack(songToTrack(s));
setOpen(false); setQuery(''); setOpen(false); setQuery('');
}} }}
role="option" aria-selected={activeIndex === i}> role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Music size={14} /></div> <div className="search-result-icon"><Music size={14} /></div>
<div> <div>
+39 -173
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check } from 'lucide-react'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -151,10 +151,6 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
); );
} }
// Module-level fallback for fromIdx — survives the dragend-before-drop race on
// macOS WKWebView AND the dataTransfer.getData('') bug on Windows WebView2.
let _dragFromIdx: number | null = null;
export default function QueuePanel() { export default function QueuePanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -201,18 +197,13 @@ export default function QueuePanel() {
return () => document.removeEventListener('mousedown', handle); return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]); }, [showCrossfadePopover]);
const [draggedIdx, setDraggedIdx] = useState<number | null>(null); // Tracks which queue index is being psy-dragged for opacity visual feedback
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null); const psyDragFromIdxRef = useRef<number | null>(null);
const isDraggingInternalRef = useRef(false);
// Refs mirror state so drop handler always reads fresh values even when
// macOS WKWebView fires dragend before drop (spec violation).
const draggedIdxRef = useRef<number | null>(null);
const dragOverIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null); const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null); const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging } = useDragDrop(); const { isDragging: isPsyDragging, startDrag } = useDragDrop();
useEffect(() => { useEffect(() => {
if (!isPsyDragging) { if (!isPsyDragging) {
@@ -221,8 +212,6 @@ export default function QueuePanel() {
} }
}, [isPsyDragging]); }, [isPsyDragging]);
const [externalDragOver, setExternalDragOver] = useState(false);
const externalDragCounterRef = useRef(0);
const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null); const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null); const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
@@ -239,13 +228,18 @@ export default function QueuePanel() {
try { parsedData = JSON.parse(detail.data); } catch { return; } try { parsedData = JSON.parse(detail.data); } catch { return; }
const dropTarget = externalDropTargetRef.current; const dropTarget = externalDropTargetRef.current;
const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
: usePlayerStore.getState().queue.length;
externalDropTargetRef.current = null; externalDropTargetRef.current = null;
setExternalDropTarget(null); setExternalDropTarget(null);
if (parsedData.type === 'song') { const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
: usePlayerStore.getState().queue.length;
if (parsedData.type === 'queue_reorder') {
const fromIdx: number = parsedData.index;
psyDragFromIdxRef.current = null;
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
} else if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx); enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'album') { } else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id); const albumData = await getAlbum(parsedData.id);
@@ -293,128 +287,11 @@ export default function QueuePanel() {
setActivePlaylist(null); setActivePlaylist(null);
}; };
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
_dragFromIdx = index;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
};
const onDragEnterItem = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
};
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
if (!isDraggingInternalRef.current) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
const target = { idx: index, before };
externalDropTargetRef.current = target;
setExternalDropTarget(target);
}
};
const onDragEnd = () => {
setDraggedIdx(null);
setDragOverIdx(null);
setExternalDropTarget(null);
externalDropTargetRef.current = null;
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
// _dragFromIdx intentionally NOT cleared here — drop fires after dragend on
// macOS WKWebView, so we need the value to survive into onDropQueue.
// It is cleared in onDropQueue after use instead.
};
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
// Snapshot drop target before clearing visual state
const droppedTarget = externalDropTargetRef.current;
// Clear visual state immediately
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
externalDragCounterRef.current = 0;
setExternalDragOver(false);
externalDropTargetRef.current = null;
setExternalDropTarget(null);
let parsedData: any = null;
try {
const raw = e.dataTransfer.getData('text/plain');
if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ }
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
// fromIdx: prefer dataTransfer value; fall back to module-level var for
// Windows WebView2 where getData() can return '' in the drop handler.
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
_dragFromIdx = null;
// toIdx: calculate from drop coordinates — avoids all ref timing issues.
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2).
let toIdx = queue.length;
if (queueListRef.current) {
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
toIdx = parseInt(items[i].dataset.queueIdx!);
break;
}
}
}
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
return;
}
// External drop (song / album dragged from elsewhere in the app)
_dragFromIdx = null;
if (!parsedData) return;
const insertIdx = droppedTarget
? (droppedTarget.before ? droppedTarget.idx : droppedTarget.idx + 1)
: usePlayerStore.getState().queue.length;
if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueueAt(tracks, insertIdx);
}
};
return ( return (
<aside <aside
ref={asideRef} ref={asideRef}
className={`queue-panel${(externalDragOver || isPsyDragging) ? ' queue-drop-active' : ''}`} className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
onDragEnter={e => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
if (!isDraggingInternalRef.current) {
externalDragCounterRef.current++;
setExternalDragOver(true);
}
}}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onMouseMove={e => { onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return; if (!isPsyDragging || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]'); const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
@@ -436,18 +313,7 @@ export default function QueuePanel() {
setExternalDropTarget(null); setExternalDropTarget(null);
} }
}} }}
onDragLeave={e => { style={{
if (!isDraggingInternalRef.current) {
externalDragCounterRef.current--;
if (externalDragCounterRef.current === 0) {
setExternalDragOver(false);
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}
}}
onDrop={onDropQueue}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0 borderLeftWidth: isQueueVisible ? 1 : 0
}} }}
> >
@@ -615,22 +481,11 @@ export default function QueuePanel() {
) : ( ) : (
queue.map((track, idx) => { queue.map((track, idx) => {
const isPlaying = idx === queueIndex; const isPlaying = idx === queueIndex;
const isDragging = draggedIdx === idx;
const isDragOver = dragOverIdx === idx;
// Highlight above or below depending on index direction
let dragStyle: React.CSSProperties = {}; let dragStyle: React.CSSProperties = {};
if (isDragging) { if (isPsyDragging && psyDragFromIdxRef.current === idx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' }; dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isDragOver && draggedIdx !== null) { } else if (isPsyDragging && externalDropTarget?.idx === idx) {
// Internal reorder indicator
if (draggedIdx > idx) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
} else if (externalDropTarget?.idx === idx && draggedIdx === null) {
// External drop insertion indicator
if (externalDropTarget.before) { if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }; dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else { } else {
@@ -648,11 +503,26 @@ export default function QueuePanel() {
e.preventDefault(); e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx); usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
}} }}
draggable onMouseDown={(e) => {
onDragStart={(e) => onDragStart(e, idx)} if (e.button !== 0) return;
onDragEnter={(e) => onDragEnterItem(e)} e.preventDefault();
onDragOver={(e) => onDragOverItem(e, idx)} const startX = e.clientX;
onDragEnd={onDragEnd} const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = idx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: idx }), label: track.title }, me.clientX, me.clientY);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
style={dragStyle} style={dragStyle}
> >
<div className="queue-item-info"> <div className="queue-item-info">
@@ -716,11 +586,7 @@ export default function QueuePanel() {
onLoad={async (id, name) => { onLoad={async (id, name) => {
try { try {
const data = await getPlaylist(id); const data = await getPlaylist(id);
const tracks: Track[] = data.songs.map(s => ({ const tracks: Track[] = data.songs.map(songToTrack);
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
if (tracks.length > 0) { if (tracks.length > 0) {
clearQueue(); clearQueue();
playTrack(tracks[0], tracks); playTrack(tracks[0], tracks);
+26 -33
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic'; import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore'; import { useDownloadModalStore } from '../store/downloadModalStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
@@ -89,40 +89,33 @@ export default function AlbumDetail() {
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, [id]); }, [id]);
const handlePlayAll = () => { const handlePlayAll = () => {
if (!album) return; if (!album) return;
const albumGenre = album.album.genre; const albumGenre = album.album.genre;
const tracks = album.songs.map(s => ({ const tracks = album.songs.map(s => {
id: s.id, title: s.title, artist: s.artist, album: s.album, const t = songToTrack(s);
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, if (!t.genre && albumGenre) t.genre = albumGenre;
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, return t;
starred: s.starred, genre: s.genre ?? albumGenre, });
})); if (tracks[0]) playTrack(tracks[0], tracks);
if (tracks[0]) playTrack(tracks[0], tracks); };
};
const handleEnqueueAll = () => { const handleEnqueueAll = () => {
if (!album) return; if (!album) return;
const albumGenre = album.album.genre; const albumGenre = album.album.genre;
const tracks = album.songs.map(s => ({ const tracks = album.songs.map(s => {
id: s.id, title: s.title, artist: s.artist, album: s.album, const t = songToTrack(s);
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, if (!t.genre && albumGenre) t.genre = albumGenre;
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, return t;
starred: s.starred, genre: s.genre ?? albumGenre, });
})); enqueue(tracks);
enqueue(tracks); };
};
const handlePlaySong = (song: SubsonicSong) => { const handlePlaySong = (song: SubsonicSong) => {
const albumGenre = album?.album.genre; const track = songToTrack(song);
const track = { if (!track.genre && album?.album.genre) track.genre = album.album.genre;
id: song.id, title: song.title, artist: song.artist, album: song.album, playTrack(track, [track]);
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, };
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred, genre: song.genre ?? albumGenre,
};
playTrack(track, [track]);
};
const handleRate = async (songId: string, rating: number) => { const handleRate = async (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating })); setRatings(r => ({ ...r, [songId]: rating }));
+22 -24
View File
@@ -6,7 +6,7 @@ import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox'; import CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm'; import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
@@ -330,22 +330,19 @@ export default function ArtistDetail() {
<div>{t('artistDetail.trackAlbum')}</div> <div>{t('artistDetail.trackAlbum')}</div>
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div> <div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
</div> </div>
{topSongs.map((song, idx) => ( {topSongs.map((song, idx) => {
<div const track = songToTrack(song);
key={song.id} return (
className="track-row" <div
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }} key={song.id}
onDoubleClick={() => playTrack(song, topSongs)} className="track-row"
onContextMenu={(e) => { style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
e.preventDefault(); onDoubleClick={() => playTrack(track, topSongs.map(songToTrack))}
const track = { onContextMenu={(e) => {
id: song.id, title: song.title, artist: song.artist, album: song.album, e.preventDefault();
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, openContextMenu(e.clientX, e.clientY, track, 'song');
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, }}
}; >
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div> <div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> <div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{song.coverArt && ( {song.coverArt && (
@@ -365,13 +362,14 @@ export default function ArtistDetail() {
{song.album} {song.album}
</div> </div>
<div className="track-duration" style={{ textAlign: 'right' }}> <div className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)} {formatDuration(song.duration)}
</div> </div>
</div> </div>
))} );
</div> })}
</> </div>
)} </>
)}
{/* Similar Artists (Last.fm) */} {/* Similar Artists (Last.fm) */}
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && ( {lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
+8 -17
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic'; import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { ListPlus, X } from 'lucide-react'; import { ListPlus, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -71,14 +71,10 @@ export default function Favorites() {
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2> <h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
<button <button
className="btn btn-surface" className="btn btn-surface"
onClick={() => { onClick={() => {
const tracks = songs.map(s => ({ const tracks = songs.map(songToTrack);
id: s.id, title: s.title, artist: s.artist, album: s.album, enqueue(tracks);
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, }}
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueue(tracks);
}}
> >
<ListPlus size={15} /> <ListPlus size={15} />
{t('favorites.enqueueAll')} {t('favorites.enqueueAll')}
@@ -93,20 +89,15 @@ export default function Favorites() {
<div /> <div />
</div> </div>
{songs.map((song, i) => { {songs.map((song, i) => {
const track = { const track = songToTrack(song);
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre,
};
return ( return (
<div <div
key={song.id} key={song.id}
className="track-row track-row-va" className="track-row track-row-va"
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }} style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
onDoubleClick={() => playTrack(song, songs)} onDoubleClick={() => playTrack(track, songs.map(songToTrack))}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row" role="row"
draggable={false}
onMouseDown={e => { onMouseDown={e => {
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
@@ -123,7 +114,7 @@ export default function Favorites() {
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
}} }}
> >
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}> <div className="track-num col-center" onClick={() => playTrack(track, songs.map(songToTrack))} style={{ cursor: 'pointer' }}>
{i + 1} {i + 1}
</div> </div>
<div className="track-info"> <div className="track-info">
+17 -14
View File
@@ -18,20 +18,23 @@ export default function OfflineLibrary() {
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId); const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
const buildTracks = (albumId: string) => { const buildTracks = (albumId: string) => {
const meta = offlineAlbums[`${serverId}:${albumId}`]; const meta = offlineAlbums[`${serverId}:${albumId}`];
if (!meta) return []; if (!meta) return [];
return meta.trackIds.flatMap(tid => { return meta.trackIds.flatMap(tid => {
const t = offlineTracks[`${serverId}:${tid}`]; const t = offlineTracks[`${serverId}:${tid}`];
if (!t) return []; if (!t) return [];
return [{ return [{
id: t.id, title: t.title, artist: t.artist, album: t.album, id: t.id, title: t.title, artist: t.artist, album: t.album,
albumId: t.albumId, artistId: t.artistId, duration: t.duration, albumId: t.albumId, artistId: t.artistId, duration: t.duration,
coverArt: t.coverArt, track: undefined, year: t.year, coverArt: t.coverArt, track: undefined, year: t.year,
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre, bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
}]; replayGainTrackDb: t.replayGainTrackDb,
}); replayGainAlbumDb: t.replayGainAlbumDb,
}; replayGainPeak: t.replayGainPeak,
}];
});
};
const handlePlay = (albumId: string) => { const handlePlay = (albumId: string) => {
const tracks = buildTracks(albumId); const tracks = buildTracks(albumId);
+77 -73
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -101,13 +101,13 @@ export default function RandomMix() {
return true; return true;
}); });
const handlePlayAll = () => { const handlePlayAll = () => {
if (selectedSuperGenre && genreMixSongs.length > 0) { if (selectedSuperGenre && genreMixSongs.length > 0) {
playTrack(genreMixSongs[0], genreMixSongs); playTrack(songToTrack(genreMixSongs[0]), genreMixSongs.map(songToTrack));
} else if (filteredSongs.length > 0) { } else if (filteredSongs.length > 0) {
playTrack(filteredSongs[0], filteredSongs); playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack));
} }
}; };
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => { const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -343,40 +343,43 @@ export default function RandomMix() {
<span>{t('randomMix.trackGenre')}</span> <span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span> <span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div> </div>
{genreMixSongs.map(song => ( {genreMixSongs.map(song => {
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }} const track = songToTrack(song);
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" return (
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }, 'song'); }} <div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onMouseDown={e => { onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
if (e.button !== 0) return; onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
e.preventDefault(); onMouseDown={e => {
const sx = e.clientX, sy = e.clientY; if (e.button !== 0) return;
const onMove = (me: MouseEvent) => { e.preventDefault();
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { const sx = e.clientX, sy = e.clientY;
document.removeEventListener('mousemove', onMove); const onMove = (me: MouseEvent) => {
document.removeEventListener('mouseup', onUp); if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre } }), label: song.title }, me.clientX, me.clientY); document.removeEventListener('mousemove', onMove);
} document.removeEventListener('mouseup', onUp);
}; psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; }
document.addEventListener('mousemove', onMove); };
document.addEventListener('mouseup', onUp); const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
}} document.addEventListener('mousemove', onMove);
> document.addEventListener('mouseup', onUp);
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}> }}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(track, genreMixSongs.map(songToTrack)); }}>
<Play size={14} fill="currentColor" /> <Play size={14} fill="currentColor" />
</button> </button>
<div className="track-info"><span className="track-title">{song.title}</span></div> <div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div> <div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div> <div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div> <div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span> <span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
</div> </div>
))} );
</div> })}
)} </div>
</div> )}
)} </div>
)}
{!selectedSuperGenre && (loading && songs.length === 0 ? ( {!selectedSuperGenre && (loading && songs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -396,40 +399,40 @@ export default function RandomMix() {
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span> <span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div> </div>
{filteredSongs.map((song) => ( {filteredSongs.map((song) => {
<div const track = songToTrack(song);
key={song.id} return (
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} <div
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }} key={song.id}
onDoubleClick={() => playTrack(song, filteredSongs)} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
role="row" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onContextMenu={e => { onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
e.preventDefault(); role="row"
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }; onContextMenu={e => {
setContextMenuSongId(song.id); e.preventDefault();
openContextMenu(e.clientX, e.clientY, track, 'song'); setContextMenuSongId(song.id);
}} openContextMenu(e.clientX, e.clientY, track, 'song');
onMouseDown={e => { }}
if (e.button !== 0) return; onMouseDown={e => {
e.preventDefault(); if (e.button !== 0) return;
const sx = e.clientX, sy = e.clientY; e.preventDefault();
const onMove = (me: MouseEvent) => { const sx = e.clientX, sy = e.clientY;
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { const onMove = (me: MouseEvent) => {
document.removeEventListener('mousemove', onMove); if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mouseup', onUp); document.removeEventListener('mousemove', onMove);
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre }; document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
} }
}; };
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
}} }}
> >
<button <button
className="btn btn-ghost" className="btn btn-ghost"
style={{ padding: 4 }} style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, filteredSongs); }} onClick={(e) => { e.stopPropagation(); playTrack(songToTrack(song), filteredSongs.map(songToTrack)); }}
data-tooltip={t('randomMix.play')} data-tooltip={t('randomMix.play')}
> >
<Play size={14} fill="currentColor" /> <Play size={14} fill="currentColor" />
@@ -537,12 +540,13 @@ export default function RandomMix() {
</div> </div>
<span className="track-duration" style={{ textAlign: 'right' }}> <span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)} {formatDuration(song.duration)}
</span> </span>
</div> </div>
))} );
</div> })}
))} </div>
))}
</div> </div>
); );
+3 -14
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Play, Search } from 'lucide-react'; import { Play, Search } from 'lucide-react';
import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic'; import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -33,17 +33,7 @@ export default function SearchResults() {
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { const playSong = (song: SubsonicSong, list: SubsonicSong[]) => {
playTrack({ playTrack(songToTrack(song), list.map(songToTrack));
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating, genre: song.genre,
}, list.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, year: s.year, bitRate: s.bitRate,
suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})));
}; };
return ( return (
@@ -94,16 +84,15 @@ export default function SearchResults() {
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }} style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
onDoubleClick={() => playSong(song, results.songs)} onDoubleClick={() => playSong(song, results.songs)}
role="row" role="row"
draggable={false}
onMouseDown={e => { onMouseDown={e => {
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
const sx = e.clientX, sy = e.clientY; const sx = e.clientX, sy = e.clientY;
const track = songToTrack(song);
const onMove = (me: MouseEvent) => { const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre };
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
} }
}; };
+10 -2
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from './playerStore';
export interface ServerProfile { export interface ServerProfile {
id: string; id: string;
@@ -147,8 +149,14 @@ export const useAuthStore = create<AuthState>()(
setDownloadFolder: (v) => set({ downloadFolder: v }), setDownloadFolder: (v) => set({ downloadFolder: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }), setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }), setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
setReplayGainEnabled: (v) => set({ replayGainEnabled: v }), setReplayGainEnabled: (v) => {
setReplayGainMode: (v) => set({ replayGainMode: v }), set({ replayGainEnabled: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainMode: (v) => {
set({ replayGainMode: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
+103 -58
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic'; import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore'; import { useOfflineStore } from './offlineStore';
@@ -75,7 +75,8 @@ interface PlayerState {
previous: () => void; previous: () => void;
seek: (progress: number) => void; seek: (progress: number) => void;
setVolume: (v: number) => void; setVolume: (v: number) => void;
setProgress: (t: number, duration: number) => void; updateReplayGainForCurrentTrack: () => void;
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void; enqueue: (tracks: Track[]) => void;
enqueueAt: (tracks: Track[], insertIndex: number) => void; enqueueAt: (tracks: Track[], insertIndex: number) => void;
clearQueue: () => void; clearQueue: () => void;
@@ -589,33 +590,62 @@ export const usePlayerStore = create<PlayerState>()(
isAudioPaused = false; isAudioPaused = false;
set({ isPlaying: true }); set({ isPlaying: true });
} else { } else {
// Cold start (app relaunch) — audio is not loaded in Rust; re-download. // Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
const gen = ++playGeneration; const gen = ++playGeneration;
const vol = get().volume; const vol = get().volume;
set({ isPlaying: true }); set({ isPlaying: true });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled // Fetch fresh track data from server to get replay gain metadata
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null getSong(currentTrack.id).then(freshSong => {
: null; const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null; // Update store with fresh track data if available
const coldServerId = useAuthStore.getState().activeServerId ?? ''; if (freshSong) set({ currentTrack: trackToPlay });
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id); const authStateCold = useAuthStore.getState();
invoke('audio_play', { const replayGainDbCold = authStateCold.replayGainEnabled
url: coldUrl, ? (authStateCold.replayGainMode === 'album' ? trackToPlay.replayGainAlbumDb : trackToPlay.replayGainTrackDb) ?? null
volume: vol, : null;
durationHint: currentTrack.duration, const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
replayGainDb: replayGainDbCold, const coldServerId = useAuthStore.getState().activeServerId ?? '';
replayGainPeak: replayGainPeakCold, const coldUrl = useOfflineStore.getState().getLocalUrl(trackToPlay.id, coldServerId) ?? buildStreamUrl(trackToPlay.id);
}).then(() => { invoke('audio_play', {
if (playGeneration === gen && currentTime > 1) { url: coldUrl,
invoke('audio_seek', { seconds: currentTime }).catch(console.error); volume: vol,
} durationHint: trackToPlay.duration,
}).catch((err: unknown) => { replayGainDb: replayGainDbCold,
if (playGeneration !== gen) return; replayGainPeak: replayGainPeakCold,
console.error('[psysonic] audio_play (cold resume) failed:', err); }).then(() => {
set({ isPlaying: false }); if (playGeneration === gen && currentTime > 1) {
}); invoke('audio_seek', { seconds: currentTime }).catch(console.error);
syncQueueToServer(queue, currentTrack, currentTime); }
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, trackToPlay, currentTime);
}).catch(() => {
if (playGeneration !== gen) return;
// Fallback to currentTrack if fetch fails
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, currentTrack, currentTime);
});
} }
}, },
@@ -753,40 +783,55 @@ export const usePlayerStore = create<PlayerState>()(
// ── server queue restore ───────────────────────────────────────────────── // ── server queue restore ─────────────────────────────────────────────────
initializeFromServerQueue: async () => { initializeFromServerQueue: async () => {
try { try {
const q = await getPlayQueue(); const q = await getPlayQueue();
if (q.songs.length > 0) { if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({ const mappedTracks: Track[] = q.songs.map(songToTrack);
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
let currentTrack = mappedTracks[0]; let currentTrack = mappedTracks[0];
let queueIndex = 0; let queueIndex = 0;
if (q.current) { if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current); const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; } if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
} }
// Prefer the server position if available; otherwise keep the // Prefer the server position if available; otherwise keep the
// localStorage-persisted currentTime (more reliable than server // localStorage-persisted currentTime (more reliable than server
// queue position, which may not flush before app close). // queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0; const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime; const localTime = get().currentTime;
set({ set({
queue: mappedTracks, queue: mappedTracks,
queueIndex, queueIndex,
currentTrack, currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime, currentTime: serverTime > 0 ? serverTime : localTime,
}); });
} }
} catch (e) { } catch (e) {
console.error('Failed to initialize queue from server', e); console.error('Failed to initialize queue from server', e);
} }
}, },
updateReplayGainForCurrentTrack: () => {
const { currentTrack, volume } = get();
if (!currentTrack || !currentTrack.id) return;
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album'
? currentTrack.replayGainAlbumDb
: currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled
? (currentTrack.replayGainPeak ?? null)
: null;
invoke('audio_update_replay_gain', {
volume,
replayGainDb,
replayGainPeak
}).catch(console.error);
},
}), }),
{ {
name: 'psysonic-player', name: 'psysonic-player',
@@ -799,7 +844,7 @@ export const usePlayerStore = create<PlayerState>()(
queueIndex: state.queueIndex, queueIndex: state.queueIndex,
currentTime: state.currentTime, currentTime: state.currentTime,
lastfmLovedCache: state.lastfmLovedCache, lastfmLovedCache: state.lastfmLovedCache,
} as Partial<PlayerState>), }),
} }
) )
); );