mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
merge: PR #9 — Replay Gain fix (trbn1) + conflict resolution
Merges songToTrack() helper and audio_update_replay_gain Tauri command from @trbn1 without losing v1.22.0 DnD/queue-management work: - All track construction sites use songToTrack() for correct replay gain - psy-drag system (mouse-event DnD) preserved across all components - enqueueAt() position-aware insertion preserved in QueuePanel - updatePlaylist / smart-save / active-playlist tracking preserved Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+2
-12
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.18.0",
|
||||
"version": "1.21.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.18.0",
|
||||
"version": "1.21.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
@@ -1494,15 +1493,6 @@
|
||||
"@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": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.21.0
|
||||
pkgver=1.22.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -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]
|
||||
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
||||
|
||||
@@ -405,6 +405,7 @@ pub fn run() {
|
||||
audio::audio_stop,
|
||||
audio::audio_seek,
|
||||
audio::audio_set_volume,
|
||||
audio::audio_update_replay_gain,
|
||||
audio::audio_set_eq,
|
||||
audio::audio_preload,
|
||||
audio::audio_set_crossfade,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
@@ -85,14 +85,6 @@ export default function AlbumTrackList({
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
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 (
|
||||
<div className="tracklist">
|
||||
<div className={`tracklist-header${' tracklist-va'}`}>
|
||||
@@ -123,7 +115,7 @@ export default function AlbumTrackList({
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
@@ -134,7 +126,7 @@ export default function AlbumTrackList({
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
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); };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -66,11 +66,7 @@ export default function ContextMenu() {
|
||||
const similar = await getSimilarSongs2(artistId);
|
||||
if (similar.length > 0) {
|
||||
const top = await getTopSongs(artistName);
|
||||
const radioTracks = [...top, ...similar].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,
|
||||
}));
|
||||
const radioTracks = [...top, ...similar].map(songToTrack);
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -132,11 +128,7 @@ export default function ContextMenu() {
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const tracks = 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,
|
||||
}));
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
|
||||
@@ -155,11 +155,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = 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,
|
||||
}));
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) { }
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
@@ -58,7 +58,7 @@ export default function LiveSearch() {
|
||||
...(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.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('');
|
||||
}}))),
|
||||
] : [];
|
||||
@@ -176,7 +176,7 @@ export default function LiveSearch() {
|
||||
return (
|
||||
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||
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('');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
|
||||
+37
-171
@@ -1,5 +1,5 @@
|
||||
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 { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -201,18 +197,13 @@ export default function QueuePanel() {
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [showCrossfadePopover]);
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<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);
|
||||
// Tracks which queue index is being psy-dragged for opacity visual feedback
|
||||
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { isDragging: isPsyDragging } = useDragDrop();
|
||||
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
@@ -221,8 +212,6 @@ export default function QueuePanel() {
|
||||
}
|
||||
}, [isPsyDragging]);
|
||||
|
||||
const [externalDragOver, setExternalDragOver] = useState(false);
|
||||
const externalDragCounterRef = useRef(0);
|
||||
const [externalDropTarget, setExternalDropTarget] = useState<{ 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; }
|
||||
|
||||
const dropTarget = externalDropTargetRef.current;
|
||||
const insertIdx = dropTarget
|
||||
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
|
||||
: usePlayerStore.getState().queue.length;
|
||||
externalDropTargetRef.current = 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);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const albumData = await getAlbum(parsedData.id);
|
||||
@@ -293,128 +287,11 @@ export default function QueuePanel() {
|
||||
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 (
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={`queue-panel${(externalDragOver || 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'; }}
|
||||
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
|
||||
onMouseMove={e => {
|
||||
if (!isPsyDragging || !queueListRef.current) return;
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
@@ -436,17 +313,6 @@ export default function QueuePanel() {
|
||||
setExternalDropTarget(null);
|
||||
}
|
||||
}}
|
||||
onDragLeave={e => {
|
||||
if (!isDraggingInternalRef.current) {
|
||||
externalDragCounterRef.current--;
|
||||
if (externalDragCounterRef.current === 0) {
|
||||
setExternalDragOver(false);
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDrop={onDropQueue}
|
||||
style={{
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0
|
||||
}}
|
||||
@@ -615,22 +481,11 @@ export default function QueuePanel() {
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isDragging = draggedIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
|
||||
// Highlight above or below depending on index direction
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isDragging) {
|
||||
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
||||
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
||||
} else if (isDragOver && draggedIdx !== null) {
|
||||
// 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
|
||||
} else if (isPsyDragging && externalDropTarget?.idx === idx) {
|
||||
if (externalDropTarget.before) {
|
||||
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
|
||||
} else {
|
||||
@@ -648,11 +503,26 @@ export default function QueuePanel() {
|
||||
e.preventDefault();
|
||||
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, idx)}
|
||||
onDragEnter={(e) => onDragEnterItem(e)}
|
||||
onDragOver={(e) => onDragOverItem(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
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}
|
||||
>
|
||||
<div className="queue-item-info">
|
||||
@@ -716,11 +586,7 @@ export default function QueuePanel() {
|
||||
onLoad={async (id, name) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks: Track[] = data.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,
|
||||
}));
|
||||
const tracks: Track[] = data.songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(tracks[0], tracks);
|
||||
|
||||
+13
-20
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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 { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -92,35 +92,28 @@ export default function AlbumDetail() {
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.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,
|
||||
starred: s.starred, genre: s.genre ?? albumGenre,
|
||||
}));
|
||||
const tracks = album.songs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
};
|
||||
|
||||
const handleEnqueueAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.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,
|
||||
starred: s.starred, genre: s.genre ?? albumGenre,
|
||||
}));
|
||||
const tracks = album.songs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
enqueue(tracks);
|
||||
};
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
const albumGenre = album?.album.genre;
|
||||
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 ?? albumGenre,
|
||||
};
|
||||
const track = songToTrack(song);
|
||||
if (!track.genre && album?.album.genre) track.genre = album.album.genre;
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
@@ -330,19 +330,16 @@ export default function ArtistDetail() {
|
||||
<div>{t('artistDetail.trackAlbum')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{topSongs.map((song, idx) => (
|
||||
{topSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||
onDoubleClick={() => playTrack(song, topSongs)}
|
||||
onDoubleClick={() => playTrack(track, topSongs.map(songToTrack))}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
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,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
@@ -368,7 +365,8 @@ export default function ArtistDetail() {
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
+5
-14
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -72,11 +72,7 @@ export default function Favorites() {
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
const tracks = 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,
|
||||
}));
|
||||
const tracks = songs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
}}
|
||||
>
|
||||
@@ -93,20 +89,15 @@ export default function Favorites() {
|
||||
<div />
|
||||
</div>
|
||||
{songs.map((song, i) => {
|
||||
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,
|
||||
};
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
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'); }}
|
||||
role="row"
|
||||
draggable={false}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
@@ -123,7 +114,7 @@ export default function Favorites() {
|
||||
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}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
|
||||
@@ -29,6 +29,9 @@ export default function OfflineLibrary() {
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
+19
-15
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -103,9 +103,9 @@ export default function RandomMix() {
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (selectedSuperGenre && genreMixSongs.length > 0) {
|
||||
playTrack(genreMixSongs[0], genreMixSongs);
|
||||
playTrack(songToTrack(genreMixSongs[0]), genreMixSongs.map(songToTrack));
|
||||
} else if (filteredSongs.length > 0) {
|
||||
playTrack(filteredSongs[0], filteredSongs);
|
||||
playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -343,10 +343,12 @@ export default function RandomMix() {
|
||||
<span>{t('randomMix.trackGenre')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
{genreMixSongs.map(song => (
|
||||
{genreMixSongs.map(song => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row"
|
||||
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'); }}
|
||||
onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
@@ -355,7 +357,7 @@ export default function RandomMix() {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
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);
|
||||
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); };
|
||||
@@ -363,7 +365,7 @@ export default function RandomMix() {
|
||||
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" />
|
||||
</button>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
@@ -372,7 +374,8 @@ export default function RandomMix() {
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -396,16 +399,17 @@ export default function RandomMix() {
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
|
||||
{filteredSongs.map((song) => (
|
||||
{filteredSongs.map((song) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, filteredSongs)}
|
||||
onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
|
||||
role="row"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
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 };
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
@@ -417,7 +421,6 @@ export default function RandomMix() {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
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, track: song.track, 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);
|
||||
}
|
||||
};
|
||||
@@ -429,7 +432,7 @@ export default function RandomMix() {
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
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')}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
@@ -540,7 +543,8 @@ export default function RandomMix() {
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Play, Search } from 'lucide-react';
|
||||
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 ArtistRow from '../components/ArtistRow';
|
||||
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 playSong = (song: SubsonicSong, list: SubsonicSong[]) => {
|
||||
playTrack({
|
||||
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,
|
||||
})));
|
||||
playTrack(songToTrack(song), list.map(songToTrack));
|
||||
};
|
||||
|
||||
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' }}
|
||||
onDoubleClick={() => playSong(song, results.songs)}
|
||||
role="row"
|
||||
draggable={false}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const track = songToTrack(song);
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
+10
-2
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
|
||||
export interface ServerProfile {
|
||||
id: string;
|
||||
@@ -147,8 +149,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
|
||||
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
|
||||
setReplayGainEnabled: (v) => set({ replayGainEnabled: v }),
|
||||
setReplayGainMode: (v) => set({ replayGainMode: v }),
|
||||
setReplayGainEnabled: (v) => {
|
||||
set({ replayGainEnabled: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setReplayGainMode: (v) => {
|
||||
set({ replayGainMode: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
|
||||
+58
-13
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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 { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
@@ -75,6 +75,7 @@ interface PlayerState {
|
||||
previous: () => void;
|
||||
seek: (progress: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
updateReplayGainForCurrentTrack: () => void;
|
||||
setProgress: (t: number, duration: number) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
enqueueAt: (tracks: Track[], insertIndex: number) => void;
|
||||
@@ -589,10 +590,42 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: true });
|
||||
} 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 vol = get().volume;
|
||||
set({ isPlaying: true });
|
||||
|
||||
// Fetch fresh track data from server to get replay gain metadata
|
||||
getSong(currentTrack.id).then(freshSong => {
|
||||
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
|
||||
// Update store with fresh track data if available
|
||||
if (freshSong) set({ currentTrack: trackToPlay });
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = authStateCold.replayGainEnabled
|
||||
? (authStateCold.replayGainMode === 'album' ? trackToPlay.replayGainAlbumDb : trackToPlay.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
const coldUrl = useOfflineStore.getState().getLocalUrl(trackToPlay.id, coldServerId) ?? buildStreamUrl(trackToPlay.id);
|
||||
invoke('audio_play', {
|
||||
url: coldUrl,
|
||||
volume: vol,
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
}
|
||||
}).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
|
||||
@@ -606,16 +639,13 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
syncQueueToServer(queue, currentTrack, currentTime);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -756,12 +786,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
try {
|
||||
const q = await getPlayQueue();
|
||||
if (q.songs.length > 0) {
|
||||
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
|
||||
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,
|
||||
}));
|
||||
const mappedTracks: Track[] = q.songs.map(songToTrack);
|
||||
|
||||
let currentTrack = mappedTracks[0];
|
||||
let queueIndex = 0;
|
||||
@@ -787,6 +812,26 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
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',
|
||||
@@ -799,7 +844,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
queueIndex: state.queueIndex,
|
||||
currentTime: state.currentTime,
|
||||
lastfmLovedCache: state.lastfmLovedCache,
|
||||
} as Partial<PlayerState>),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user