mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
fix: replay gain not applying to tracks
Replay gain was not working because track objects were created manually without including replay gain metadata from the Subsonic API response. Changes: - Add songToTrack() helper function to properly map SubsonicSong to Track with replayGainTrackDb, replayGainAlbumDb, and replayGainPeak fields - Add audio_update_replay_gain Tauri command for dynamic volume recalculation when replay gain settings change mid-playback - Add updateReplayGainForCurrentTrack() to recalculate volume when toggling replay gain setting - Fetch fresh track data on cold resume (app relaunch) to ensure replay gain values are current from server - Update all files that create track objects to use songToTrack() Fixes issue where toggling replay gain ON/OFF or changing between track/album mode had no effect on currently playing or newly played tracks.
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",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
@@ -81,15 +81,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">
|
||||||
@@ -121,13 +113,13 @@ 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"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: songToTrack(song) }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } from 'lucide-react';
|
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
|
||||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -265,15 +265,11 @@ export default function QueuePanel() {
|
|||||||
if (!parsedData) return;
|
if (!parsedData) return;
|
||||||
if (parsedData.type === 'song') {
|
if (parsedData.type === 'song') {
|
||||||
enqueue([parsedData.track]);
|
enqueue([parsedData.track]);
|
||||||
} else if (parsedData.type === 'album') {
|
} else if (parsedData.type === 'album') {
|
||||||
const albumData = await getAlbum(parsedData.id);
|
const albumData = await getAlbum(parsedData.id);
|
||||||
const tracks: Track[] = albumData.songs.map(s => ({
|
const tracks: Track[] = 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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -523,20 +519,16 @@ export default function QueuePanel() {
|
|||||||
{loadModalOpen && (
|
{loadModalOpen && (
|
||||||
<LoadPlaylistModal
|
<LoadPlaylistModal
|
||||||
onClose={() => setLoadModalOpen(false)}
|
onClose={() => setLoadModalOpen(false)}
|
||||||
onLoad={async (id) => {
|
onLoad={async (id) => {
|
||||||
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,
|
if (tracks.length > 0) {
|
||||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
clearQueue();
|
||||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
playTrack(tracks[0], tracks);
|
||||||
}));
|
}
|
||||||
if (tracks.length > 0) {
|
setLoadModalOpen(false);
|
||||||
clearQueue();
|
} catch (e) {
|
||||||
playTrack(tracks[0], tracks);
|
|
||||||
}
|
|
||||||
setLoadModalOpen(false);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load playlist', e);
|
console.error('Failed to load playlist', e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
+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) && (
|
||||||
|
|||||||
+22
-30
@@ -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';
|
||||||
@@ -69,14 +69,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')}
|
||||||
@@ -90,27 +86,23 @@ export default function Favorites() {
|
|||||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||||
<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,
|
return (
|
||||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
|
<div
|
||||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre,
|
key={song.id}
|
||||||
};
|
className="track-row track-row-va"
|
||||||
return (
|
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||||
<div
|
onDoubleClick={() => playTrack(track, songs.map(songToTrack))}
|
||||||
key={song.id}
|
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||||
className="track-row track-row-va"
|
role="row"
|
||||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
draggable
|
||||||
onDoubleClick={() => playTrack(song, songs)}
|
onDragStart={e => {
|
||||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
role="row"
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||||
draggable
|
}}
|
||||||
onDragStart={e => {
|
>
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
<div className="track-num col-center" onClick={() => playTrack(track, songs.map(songToTrack))} style={{ cursor: 'pointer' }}>
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="track-num col-center" onClick={() => playTrack(song, songs)} 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);
|
||||||
|
|||||||
+56
-56
@@ -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';
|
||||||
@@ -99,13 +99,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();
|
||||||
@@ -341,29 +341,32 @@ 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" draggable
|
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' }}
|
||||||
onDragStart={e => {
|
onDoubleClick={() => playTrack(songToTrack(song), genreMixSongs.map(songToTrack))} role="row" draggable
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||||
e.dataTransfer.setData('text/plain', 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 } }));
|
onDragStart={e => {
|
||||||
}}
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
>
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||||
<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(songToTrack(song), 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' }}>
|
||||||
@@ -383,34 +386,30 @@ 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' }}
|
||||||
draggable
|
onDoubleClick={() => playTrack(songToTrack(song), filteredSongs.map(songToTrack))}
|
||||||
onContextMenu={e => {
|
role="row"
|
||||||
e.preventDefault();
|
draggable
|
||||||
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');
|
||||||
onDragStart={e => {
|
}}
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
onDragStart={e => {
|
||||||
const track = {
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', 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, genre: song.genre,
|
>
|
||||||
};
|
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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" />
|
||||||
@@ -518,12 +517,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';
|
||||||
@@ -31,17 +31,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 (
|
||||||
@@ -93,16 +83,11 @@ export default function SearchResults() {
|
|||||||
onDoubleClick={() => playSong(song, results.songs)}
|
onDoubleClick={() => playSong(song, results.songs)}
|
||||||
role="row"
|
role="row"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
const track = {
|
const track = songToTrack(song);
|
||||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||||
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,
|
|
||||||
};
|
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
|
|||||||
+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;
|
||||||
clearQueue: () => void;
|
clearQueue: () => void;
|
||||||
|
|
||||||
@@ -588,33 +589,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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -736,40 +766,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',
|
||||||
@@ -782,7 +827,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