mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
Generated
+2
-12
@@ -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,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')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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); };
|
||||||
|
|||||||
@@ -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
@@ -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')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user