diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2635b1..20cc4363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.5] - 2026-03-12 + +### Added +- **Image Caching**: Integrated IndexedDB-based image caching for cover art and artist images, providing significantly faster loading times for frequently accessed items. +- **Improved Artist Discovery**: Faster scrolling in the Artists list using color-coded initial-based avatars for quick visual identification. +- **Random Albums**: New discovery page for exploring your library with random album selections. +- **Help & Documentation**: Added a dedicated help page for better user onboarding. + +### Changed +- **Optimized UI**: Instant "Now Playing" status updates via local state filtering for a more responsive experience. +- **Enhanced Data Flow**: General performance improvements in server communication and state management. + ## [1.0.4] - 2026-03-12 ### Added diff --git a/package.json b/package.json index ef389ef5..6955bf82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.0.4", + "version": "1.0.5", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bedc48b8..a98dcd95 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.0.4" +version = "1.0.5" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ca79274d..11ecd433 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.0.4" +version = "1.0.5" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 60f64149..2aed4e36 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.0.4", + "version": "1.0.5", "identifier": "dev.psysonic.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index fd37e7eb..7ed5fb43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ import AlbumDetail from './pages/AlbumDetail'; import LabelAlbums from './pages/LabelAlbums'; import Statistics from './pages/Statistics'; import Playlists from './pages/Playlists'; +import Help from './pages/Help'; +import RandomAlbums from './pages/RandomAlbums'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; import { useAuthStore } from './store/authStore'; @@ -142,6 +144,7 @@ function AppShell() { } /> } /> + } /> } /> } /> } /> @@ -152,6 +155,7 @@ function AppShell() { } /> } /> } /> + } /> diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index ee7c5286..12768d8d 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -303,6 +303,12 @@ export function buildStreamUrl(id: string): string { return `${baseUrl}/rest/stream.view?${p.toString()}`; } +/** Stable cache key for cover art — does not include ephemeral auth params. */ +export function coverArtCacheKey(id: string, size = 256): string { + const server = useAuthStore.getState().getActiveServer(); + return `${server?.id ?? '_'}:cover:${id}:${size}`; +} + export function buildCoverArtUrl(id: string, size = 256): string { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index bbfcb17b..6117d2f6 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { Play } from 'lucide-react'; -import { SubsonicAlbum, buildCoverArtUrl } from '../api/subsonic'; +import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; +import CachedImage from './CachedImage'; interface AlbumCardProps { album: SubsonicAlbum; @@ -37,7 +38,7 @@ export default function AlbumCard({ album }: AlbumCardProps) { >
{coverUrl ? ( - {`${album.name} + ) : (
diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx new file mode 100644 index 00000000..52d90a54 --- /dev/null +++ b/src/components/CachedImage.tsx @@ -0,0 +1,21 @@ +import React, { useEffect, useState } from 'react'; +import { getCachedUrl } from '../utils/imageCache'; + +interface CachedImageProps extends React.ImgHTMLAttributes { + src: string; + cacheKey: string; +} + +export function useCachedUrl(fetchUrl: string, cacheKey: string): string { + const [resolved, setResolved] = useState(''); + useEffect(() => { + if (!fetchUrl) { setResolved(''); return; } + getCachedUrl(fetchUrl, cacheKey).then(setResolved); + }, [fetchUrl, cacheKey]); + return resolved || fetchUrl; +} + +export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) { + const resolvedSrc = useCachedUrl(src, cacheKey); + return ; +} diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 17571f2c..582d13bf 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -4,7 +4,8 @@ import { ChevronDown, Repeat, Repeat1, Square, Music } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; -import { buildCoverArtUrl } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import CachedImage, { useCachedUrl } from './CachedImage'; import { useTranslation } from 'react-i18next'; function formatTime(seconds: number): string { @@ -63,20 +64,18 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number }) const handleSeek = useCallback((e: React.ChangeEvent) => seek(parseFloat(e.target.value)), [seek]); return ( -
-
- {formatTime(currentTime)} -
- -
- {formatTime(duration)} +
+ {formatTime(currentTime)} +
+
+ {formatTime(duration)}
); }); @@ -112,9 +111,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const toggleRepeat = usePlayerStore(s => s.toggleRepeat); const playTrack = usePlayerStore(s => s.playTrack); - const duration = currentTrack?.duration ?? 0; - const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; - const upcoming = queue.slice(queueIndex + 1, queueIndex + 15); + const duration = currentTrack?.duration ?? 0; + const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; + const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; + const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); + const upcoming = queue.slice(queueIndex + 1, queueIndex + 15); // Close on Escape useEffect(() => { @@ -126,26 +127,59 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { return (
{/* Crossfading blurred background */} - + - - {/* Bottom: meta + progress + controls — centered across full width */} -
-
-
-

{currentTrack?.title ?? '—'}

-

{currentTrack?.artist ?? '—'}

-

{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}

- {(currentTrack?.bitRate || currentTrack?.suffix) && ( - - {[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')} - - )} -
- - {/* Progress bar — isolated sub-component */} - -
- - {/* Transport controls */} -
- - - - - -
-
); } diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index c08276bb..012c7276 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -1,26 +1,89 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, ListPlus } from 'lucide-react'; -import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, getAlbum } from '../api/subsonic'; +import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; +import CachedImage, { useCachedUrl } from './CachedImage'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; -export default function Hero() { - const { t } = useTranslation(); - const [album, setAlbum] = useState(null); - const navigate = useNavigate(); +const INTERVAL_MS = 10000; + +// Crossfading background — same layer pattern as FullscreenPlayer +function HeroBg({ url }: { url: string }) { + const [layers, setLayers] = useState>(() => + url ? [{ url, id: 0, visible: true }] : [] + ); + const counter = useRef(1); useEffect(() => { - let cancelled = false; - getRandomAlbums(1).then(albums => { - if (!cancelled && albums[0]) setAlbum(albums[0]); - }).catch(() => {}); - return () => { cancelled = true; }; + if (!url) return; + const id = counter.current++; + setLayers(prev => [...prev, { url, id, visible: false }]); + const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20); + const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900); + return () => { clearTimeout(t1); clearTimeout(t2); }; + }, [url]); + + return ( + <> + {layers.map(layer => ( +