mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat: Waveform seekbar, MilkDrop visualizer, EQ bars, in-app browser, and UX improvements (v1.3.0)
- PlayerBar redesigned: canvas waveform seekbar (500 bars, blue→mauve gradient + glow) replaces thin slider; new flex layout; queue toggle moved to content header (consistent with sidebar pattern) - MilkDrop visualizer in Ambient Stage via Butterchurn; hidden audio analysis, preset shuffle, smooth blend transitions - Tracklist: animated EQ bars for playing track, play icon when paused, align-items: center fix - Artist pages: Last.fm + Wikipedia open in native Tauri WebviewWindow (in-app browser) - Hero/Discover deduplication: single fetch of 20 random albums split between sections - Update checker: runs every 10 minutes during runtime; version shown without v-prefix - Settings version: now read from package.json instead of hardcoded - Help page: new Random Mix section, updated Playback + Library sections, improved accordion styling - Bump version to 1.3.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,7 @@ export default function AlbumDetail() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
|
||||
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
@@ -379,8 +380,8 @@ export default function AlbumDetail() {
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
|
||||
<div>{t('albumDetail.trackRating')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackRating')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
</div>
|
||||
|
||||
@@ -435,11 +436,13 @@ export default function AlbumDetail() {
|
||||
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === song.id
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar"/><span className="eq-bar"/><span className="eq-bar"/></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
@@ -463,7 +466,7 @@ export default function AlbumDetail() {
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => handleRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
<div className="track-duration" style={{ textAlign: 'center' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -77,7 +77,16 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const openLink = (url: string) => open(url);
|
||||
const openLink = (url: string, title: string) => {
|
||||
const label = `browser_${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
url,
|
||||
title,
|
||||
width: 1100,
|
||||
height: 780,
|
||||
center: true,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
@@ -183,12 +192,12 @@ export default function ArtistDetail() {
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, `${artist.name} — Last.fm`)}>
|
||||
<LastfmIcon size={14} />
|
||||
Last.fm
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl)}>
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}>
|
||||
<ExternalLink size={14} />
|
||||
Wikipedia
|
||||
</button>
|
||||
|
||||
+14
-1
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench } from 'lucide-react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FaqItem { q: string; a: string; }
|
||||
@@ -42,6 +42,9 @@ export default function Help() {
|
||||
{ q: t('help.q6'), a: t('help.a6') },
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q23'), a: t('help.a23') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -51,6 +54,7 @@ export default function Help() {
|
||||
{ q: t('help.q9'), a: t('help.a9') },
|
||||
{ q: t('help.q10'), a: t('help.a10') },
|
||||
{ q: t('help.q11'), a: t('help.a11') },
|
||||
{ q: t('help.q25'), a: t('help.a25') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -71,6 +75,15 @@ export default function Help() {
|
||||
{ q: t('help.q17'), a: t('help.a17') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Shuffle size={18} />,
|
||||
title: t('help.s7'),
|
||||
items: [
|
||||
{ q: t('help.q26'), a: t('help.a26') },
|
||||
{ q: t('help.q27'), a: t('help.a27') },
|
||||
{ q: t('help.q28'), a: t('help.a28') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Wrench size={18} />,
|
||||
title: t('help.s6'),
|
||||
|
||||
+5
-3
@@ -8,6 +8,7 @@ export default function Home() {
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -15,12 +16,13 @@ export default function Home() {
|
||||
Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
]).then(([s, n, r, f]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setRandom(r);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
@@ -47,7 +49,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<Hero />
|
||||
<Hero albums={heroAlbums} />
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
{loading ? (
|
||||
|
||||
+1
-28
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Plus } from 'lucide-react';
|
||||
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AUDIOBOOK_GENRES = [
|
||||
@@ -42,7 +42,6 @@ export default function RandomMix() {
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||
@@ -56,7 +55,6 @@ export default function RandomMix() {
|
||||
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
|
||||
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
|
||||
const [genreMixLoading, setGenreMixLoading] = useState(false);
|
||||
const [genreMixMatchedGenres, setGenreMixMatchedGenres] = useState<string[]>([]);
|
||||
|
||||
const fetchSongs = () => {
|
||||
setLoading(true);
|
||||
@@ -124,7 +122,6 @@ export default function RandomMix() {
|
||||
const matched = serverGenres
|
||||
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
|
||||
.map(sg2 => sg2.value);
|
||||
setGenreMixMatchedGenres(matched);
|
||||
setGenreMixLoading(true);
|
||||
setGenreMixSongs([]);
|
||||
|
||||
@@ -154,27 +151,6 @@ export default function RandomMix() {
|
||||
setGenreMixLoading(false);
|
||||
};
|
||||
|
||||
const loadMoreGenreMix = async () => {
|
||||
if (!genreMixMatchedGenres.length) return;
|
||||
setGenreMixLoading(true);
|
||||
try {
|
||||
const perGenre = Math.max(1, Math.ceil(10 / genreMixMatchedGenres.length));
|
||||
const settled = await Promise.allSettled(genreMixMatchedGenres.map(g => getRandomSongs(perGenre, g, 45000)));
|
||||
const combined = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
|
||||
for (let i = combined.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[combined[i], combined[j]] = [combined[j], combined[i]];
|
||||
}
|
||||
enqueue(combined.slice(0, 10).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,
|
||||
})));
|
||||
} finally {
|
||||
setGenreMixLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -320,9 +296,6 @@ export default function RandomMix() {
|
||||
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }} onClick={loadMoreGenreMix} disabled={genreMixLoading}>
|
||||
<Plus size={14} /> {t('randomMix.genreMixLoadMore')}
|
||||
</button>
|
||||
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
|
||||
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle
|
||||
@@ -465,7 +466,7 @@ export default function Settings() {
|
||||
Psysonic
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.aboutVersion')} 1.0.12
|
||||
{t('settings.aboutVersion')} {appVersion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user