diff --git a/CHANGELOG.md b/CHANGELOG.md index bb1fc6f3..6338181f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ 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.18.0] - 2026-03-27 + +### Added + +- **Offline Mode *(Beta — tested on CachyOS only)***: Albums can now be cached for offline playback via the new "Cache Offline" button in the album header. Cached albums are accessible in the new **Offline Library** page. On launch without internet, the app automatically navigates there if cached content is available — no blocking overlay. A slim non-blocking banner shows while in offline mode. Offline tracks are removed when clearing the cache. +- **Settings — Cache section improvements**: Live usage display (image cache + offline tracks). Adjustable limit now goes up to 5 GB. When the limit is reached, the oldest image cache entries are evicted automatically (offline albums are not auto-removed). "Clear Cache" button with confirmation removes both image cache and all offline albums. +- **MPRIS — Seek support**: The Plasma (and other MPRIS2-compatible) seekbar now works correctly. Seek and SetPosition events from the OS are forwarded to the audio engine. Position is synced every 500 ms while playing so the OS overlay stays accurate. +- **Lyrics caching**: Fetched lyrics are cached in memory for the session. Switching between Queue and Lyrics tabs no longer re-fetches from lrclib.net. +- **2 New Themes** *(Movies)*: + - **Barb & Ken** — Barbie dreamhouse universe. Deep magenta dark, polka-dot sidebar, glitter shimmer animation on track name, Ken powder blue for artist name and volume slider. + - **Toy Tale** — Toy Story. Dark warm toy-chest brown main, Andy's iconic cloud-wallpaper sky-blue sidebar, Woody sheriff-star gold track name, Buzz Lightyear purple for active queue item and volume slider. + +### Changed + +- **Hero carousel — background crossfade**: The blurred background no longer flickers when switching albums. The last resolved URL is held until the new one is ready, so the old background stays visible until the new one loads. +- **AlbumDetail — Download hint**: Removed the inline hint text from the album header. The explanation (server zips first — may take a moment) is now in the Help FAQ. + +### Fixed + +- **Performance — Home page scroll**: `AlbumCard` subscribed to two large Zustand record objects (`tracks`, `albums`) per card — 96+ selector calls across a typical home page. Replaced with a single boolean selector per card. Added `React.memo` to prevent re-renders when parent rows reload. +- **Middle Earth theme — active queue item contrast**: Track title was invisible (dark text on dark background). Fixed to bright gold. Tech info bar text also corrected. + +--- + ## [1.17.2] - 2026-03-26 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c6d683b1..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,264 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What is Psysonic - -A desktop music player (Tauri v2 + React 18 + TypeScript) for Subsonic API-compatible servers (Navidrome, Gonic, etc.). UI is styled after the Catppuccin aesthetic with glassmorphism effects. - -## Commands - -```bash -# Dev mode (Linux — uses X11 backend to avoid WebKit compositing issues) -npm run tauri:dev -# Equivalent to: GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev - -# Production build -npm run tauri:build - -# Frontend-only dev server (no Tauri shell) -npm run dev - -# Type-check + bundle frontend -npm run build -``` - -There are no test scripts. TypeScript compilation (`tsc`) is part of the build. - -## Architecture - -### Stack -- **Frontend**: React 18 + TypeScript + Vite, served inside a Tauri WebView -- **Backend**: Rust (Tauri v2) — handles tray icon, media key shortcuts, `exit_app` command, and the full audio engine -- **State**: Zustand stores (no Redux) -- **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section. -- **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt) -- **Last.fm**: `src/api/lastfm.ts` — direct Last.fm API integration (scrobbling, Now Playing, love/unlove, similar artists, top stats, recent tracks). API key + secret from `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET` env vars (bundled at build time). -- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English, German, French, Dutch) - -### Key files - -| File | Role | -|---|---| -| `src/api/subsonic.ts` | All Subsonic REST calls + `buildStreamUrl` / `buildCoverArtUrl` / `buildDownloadUrl` helpers. Also exports `pingWithCredentials()`, `coverArtCacheKey()`, `reportNowPlaying()`. `getRandomSongs` includes a `_t` timestamp param to prevent browser/axios caching. | -| `src/api/lastfm.ts` | Last.fm API: scrobble, updateNowPlaying, love/unlove, getTrackLoved, getSimilarArtists, getTopArtists/Albums/Tracks, getRecentTracks, getUserInfo. Auth via session key stored in `authStore`. | -| `src/utils/imageCache.ts` | IndexedDB image cache (30-day TTL) + in-memory object URL Map. `getCachedUrl(fetchUrl, cacheKey)` is the main entry point. Capped at 150 entries with LRU eviction + `URL.revokeObjectURL`. Max 5 concurrent fetches. | -| `src/components/CachedImage.tsx` | Drop-in `` replacement that resolves via the image cache. Also exports `useCachedUrl(fetchUrl, cacheKey)` hook for CSS background-image use cases. Uses cancellation flag to prevent setState on unmounted components. | -| `src/components/TooltipPortal.tsx` | Global tooltip system. Listens for `mouseover`/`mouseout` on `document`, reads `data-tooltip` / `data-tooltip-pos` / `data-tooltip-wrap` attributes, renders via `createPortal` to `document.body` at `z-index: 99999`. Mounted once in `App.tsx`. Use `data-tooltip` instead of native `title=` everywhere — `title=` produces unstyled OS tooltips. | -| `src/components/CustomSelect.tsx` | Styled portal-based dropdown replacing native ` auth.setMaxCacheMb(Number(e.target.value))} @@ -379,6 +423,28 @@ export default function Settings() { id="cache-size-slider" /> + {showClearConfirm ? ( +
+
{t('settings.cacheClearWarning')}
+
+ + +
+
+ ) : ( + + )} diff --git a/src/store/offlineStore.ts b/src/store/offlineStore.ts new file mode 100644 index 00000000..a49341b6 --- /dev/null +++ b/src/store/offlineStore.ts @@ -0,0 +1,245 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { invoke } from '@tauri-apps/api/core'; +import { buildStreamUrl } from '../api/subsonic'; +import type { SubsonicSong } from '../api/subsonic'; + +export interface OfflineTrackMeta { + id: string; + serverId: string; + localPath: string; + title: string; + artist: string; + album: string; + albumId: string; + artistId?: string; + suffix: string; + duration: number; + bitRate?: number; + coverArt?: string; + year?: number; + genre?: string; + replayGainTrackDb?: number; + replayGainAlbumDb?: number; + replayGainPeak?: number; + cachedAt: string; +} + +export interface OfflineAlbumMeta { + id: string; + serverId: string; + name: string; + artist: string; + coverArt?: string; + year?: number; + trackIds: string[]; +} + +export interface DownloadJob { + trackId: string; + albumId: string; + albumName: string; + trackTitle: string; + trackIndex: number; + totalTracks: number; + status: 'queued' | 'downloading' | 'done' | 'error'; +} + +interface OfflineState { + tracks: Record; // key: `${serverId}:${trackId}` + albums: Record; // key: `${serverId}:${albumId}` + jobs: DownloadJob[]; + + isDownloaded: (trackId: string, serverId: string) => boolean; + isAlbumDownloaded: (albumId: string, serverId: string) => boolean; + isAlbumDownloading: (albumId: string) => boolean; + getLocalUrl: (trackId: string, serverId: string) => string | null; + downloadAlbum: ( + albumId: string, + albumName: string, + albumArtist: string, + coverArt: string | undefined, + year: number | undefined, + songs: SubsonicSong[], + serverId: string, + ) => Promise; + deleteAlbum: (albumId: string, serverId: string) => Promise; + clearAll: (serverId: string) => Promise; + getAlbumProgress: (albumId: string) => { done: number; total: number } | null; +} + +export const useOfflineStore = create()( + persist( + (set, get) => ({ + tracks: {}, + albums: {}, + jobs: [], + + isDownloaded: (trackId, serverId) => + !!get().tracks[`${serverId}:${trackId}`], + + isAlbumDownloaded: (albumId, serverId) => { + const album = get().albums[`${serverId}:${albumId}`]; + if (!album || album.trackIds.length === 0) return false; + return album.trackIds.every(tid => !!get().tracks[`${serverId}:${tid}`]); + }, + + isAlbumDownloading: (albumId) => + get().jobs.some( + j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading') + ), + + getLocalUrl: (trackId, serverId) => { + const meta = get().tracks[`${serverId}:${trackId}`]; + if (!meta) return null; + return `psysonic-local://${meta.localPath}`; + }, + + clearAll: async (serverId) => { + const albumKeys = Object.keys(get().albums).filter(k => k.startsWith(`${serverId}:`)); + for (const key of albumKeys) { + const albumId = key.slice(`${serverId}:`.length); + await get().deleteAlbum(albumId, serverId); + } + }, + + getAlbumProgress: (albumId) => { + const albumJobs = get().jobs.filter(j => j.albumId === albumId); + if (albumJobs.length === 0) return null; + const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length; + return { done, total: albumJobs.length }; + }, + + downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => { + const CONCURRENCY = 2; + const trackIds = songs.map(s => s.id); + + // Register album shell + queue jobs + set(state => ({ + albums: { + ...state.albums, + [`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds }, + }, + jobs: [ + ...state.jobs.filter(j => j.albumId !== albumId), + ...songs.map((s, i) => ({ + trackId: s.id, + albumId, + albumName, + trackTitle: s.title, + trackIndex: i, + totalTracks: songs.length, + status: 'queued' as const, + })), + ], + })); + + // Download in batches of CONCURRENCY + for (let i = 0; i < songs.length; i += CONCURRENCY) { + const batch = songs.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async song => { + set(state => ({ + jobs: state.jobs.map(j => + j.trackId === song.id && j.albumId === albumId + ? { ...j, status: 'downloading' } + : j, + ), + })); + + const suffix = song.suffix || 'mp3'; + const url = buildStreamUrl(song.id); + + try { + const localPath = await invoke('download_track_offline', { + trackId: song.id, + serverId, + url, + suffix, + }); + + set(state => ({ + tracks: { + ...state.tracks, + [`${serverId}:${song.id}`]: { + id: song.id, + serverId, + localPath, + title: song.title, + artist: song.artist, + album: song.album, + albumId: song.albumId, + artistId: song.artistId, + suffix, + duration: song.duration, + bitRate: song.bitRate, + coverArt: song.coverArt, + year: song.year, + genre: song.genre, + replayGainTrackDb: song.replayGain?.trackGain, + replayGainAlbumDb: song.replayGain?.albumGain, + replayGainPeak: song.replayGain?.trackPeak, + cachedAt: new Date().toISOString(), + }, + }, + jobs: state.jobs.map(j => + j.trackId === song.id && j.albumId === albumId + ? { ...j, status: 'done' } + : j, + ), + })); + } catch { + set(state => ({ + jobs: state.jobs.map(j => + j.trackId === song.id && j.albumId === albumId + ? { ...j, status: 'error' } + : j, + ), + })); + } + }), + ); + } + + // Clear completed jobs after a short delay + setTimeout(() => { + set(state => ({ + jobs: state.jobs.filter( + j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'), + ), + })); + }, 2500); + }, + + deleteAlbum: async (albumId, serverId) => { + const album = get().albums[`${serverId}:${albumId}`]; + if (!album) return; + + await Promise.all( + album.trackIds.map(async trackId => { + const meta = get().tracks[`${serverId}:${trackId}`]; + if (!meta) return; + await invoke('delete_offline_track', { + trackId, + serverId, + suffix: meta.suffix, + }).catch(() => {}); + }), + ); + + set(state => { + const tracks = { ...state.tracks }; + album.trackIds.forEach(tid => delete tracks[`${serverId}:${tid}`]); + const albums = { ...state.albums }; + delete albums[`${serverId}:${albumId}`]; + return { tracks, albums }; + }); + }, + }), + { + name: 'psysonic-offline', + storage: createJSONStorage(() => localStorage), + partialize: state => ({ tracks: state.tracks, albums: state.albums }), + }, + ), +); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index a03e2ed5..00462676 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event'; import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; +import { useOfflineStore } from './offlineStore'; export interface Track { id: string; @@ -186,7 +187,8 @@ function handleAudioProgress(current_time: number, duration: number) { : (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null)); if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) { gaplessPreloadingId = nextTrack.id; - const nextUrl = buildStreamUrl(nextTrack.id); + const serverId = useAuthStore.getState().activeServerId ?? ''; + const nextUrl = useOfflineStore.getState().getLocalUrl(nextTrack.id, serverId) ?? buildStreamUrl(nextTrack.id); if (gaplessEnabled) { // Gapless ON: decode + chain directly into the Sink now, 30 s in // advance. By the time the track boundary arrives, the next source is @@ -340,6 +342,7 @@ export function initAudioListeners(): () => void { // Rust souvlaki MediaControls so the OS media overlay stays accurate. let prevTrackId: string | null = null; let prevIsPlaying: boolean | null = null; + let lastMprisPositionUpdate = 0; const unsubMpris = usePlayerStore.subscribe((state) => { const { currentTrack, isPlaying, currentTime } = state; @@ -359,13 +362,26 @@ export function initAudioListeners(): () => void { }).catch(() => {}); } - // Update playback state when it changes - if (isPlaying !== prevIsPlaying) { + // Update playback state on play/pause change + const playbackChanged = isPlaying !== prevIsPlaying; + if (playbackChanged) { prevIsPlaying = isPlaying; + lastMprisPositionUpdate = Date.now(); invoke('mpris_set_playback', { playing: isPlaying, positionSecs: currentTime > 0 ? currentTime : null, }).catch(() => {}); + return; + } + + // Keep position in sync while playing — update every ~500 ms so Plasma + // always shows the correct time without interpolation gaps. + if (isPlaying && Date.now() - lastMprisPositionUpdate >= 500) { + lastMprisPositionUpdate = Date.now(); + invoke('mpris_set_playback', { + playing: true, + positionSecs: currentTime, + }).catch(() => {}); } }); @@ -502,8 +518,8 @@ export const usePlayerStore = create()( isPlaying: true, // optimistic — reverted on error }); - const url = buildStreamUrl(track.id); const authState = useAuthStore.getState(); + const url = useOfflineStore.getState().getLocalUrl(track.id, authState.activeServerId ?? '') ?? buildStreamUrl(track.id); const replayGainDb = authState.replayGainEnabled ? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null : null; @@ -566,8 +582,10 @@ export const usePlayerStore = create()( ? (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: buildStreamUrl(currentTrack.id), + url: coldUrl, volume: vol, durationHint: currentTrack.duration, replayGainDb: replayGainDbCold, diff --git a/src/styles/components.css b/src/styles/components.css index 289b47aa..b423b85e 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -399,6 +399,21 @@ box-shadow: var(--shadow-sm); } +.album-card-offline-badge { + position: absolute; + top: 6px; + right: 6px; + background: color-mix(in srgb, var(--accent) 85%, transparent); + color: var(--ctp-crust); + border-radius: var(--radius-sm); + padding: 3px 4px; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + pointer-events: none; +} + .album-card-play-overlay { position: absolute; inset: 0; @@ -591,6 +606,82 @@ } /* ─ Album Detail ─ */ +/* ─── Offline Library ─── */ +.offline-library { + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.offline-library-header { + display: flex; + align-items: center; + gap: 14px; + color: var(--accent); +} + +.offline-library-title { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.offline-library-count { + font-size: 13px; + color: var(--text-secondary); + margin: 2px 0 0; +} + +.offline-library-card .album-card-info { + gap: 3px; +} + +.offline-library-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 2px; +} + +.offline-library-enqueue { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 10px; + padding: 2px 6px; + cursor: pointer; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.offline-library-enqueue:hover { + color: var(--accent); + border-color: var(--accent); +} + +.offline-library-tracks { + font-size: 10px; + color: var(--text-muted); +} + +.offline-library-delete { + background: none; + border: none; + padding: 2px 4px; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + border-radius: var(--radius-sm); + transition: color var(--transition-fast); +} + +.offline-library-delete:hover { + color: var(--color-error, #e05050); +} + .album-detail { display: flex; flex-direction: column; @@ -853,6 +944,28 @@ font-variant-numeric: tabular-nums; } +/* ─ Offline cache button ─ */ +.offline-cache-btn { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.offline-cache-btn--cached { + color: var(--color-star-active, var(--accent)); + border-color: var(--color-star-active, var(--accent)); +} + +.offline-cache-btn--progress { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + opacity: 0.75; + padding: 6px 10px; +} + /* ─ Download folder modal ─ */ .download-folder-pick-row { display: flex; diff --git a/src/styles/layout.css b/src/styles/layout.css index aa4958bc..81d34f38 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -301,6 +301,35 @@ cursor: default; } +/* ─── Sidebar offline download queue ─── */ +.sidebar-offline-queue { + margin: 4px var(--space-1) 0; + padding: 6px 10px; + border-radius: var(--radius-md); + background: var(--accent-dim); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + color: var(--accent); + overflow: hidden; +} + +.sidebar-offline-queue--collapsed { + justify-content: center; + padding: 6px; +} + +@keyframes spin-slow { + to { transform: rotate(360deg); } +} + +.spin-slow { + animation: spin-slow 2s linear infinite; + flex-shrink: 0; +} + /* ─── Main Content ─── */ .main-content { grid-area: main; @@ -343,6 +372,47 @@ contain: paint; } +/* ─── Offline Banner ─── */ +.offline-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + background: color-mix(in srgb, var(--accent) 12%, var(--bg-sidebar)); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--accent); + font-size: 12px; + font-weight: 500; + flex-shrink: 0; +} + +.offline-banner span { + flex: 1; +} + +.offline-banner-retry { + display: flex; + align-items: center; + gap: 4px; + background: none; + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: var(--radius-sm); + color: var(--accent); + font-size: 11px; + padding: 2px 8px; + cursor: pointer; + transition: background var(--transition-fast); +} + +.offline-banner-retry:hover { + background: color-mix(in srgb, var(--accent) 15%, transparent); +} + +.offline-banner-retry:disabled { + opacity: 0.5; + cursor: default; +} + /* ─── Player Bar ─── */ .player-bar { grid-area: player; diff --git a/src/styles/theme.css b/src/styles/theme.css index 5a463928..059b9737 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -5303,12 +5303,12 @@ input[type="range"]:hover::-webkit-slider-thumb { /* ── Active queue / now-playing: the bearer of the Ring ── */ [data-theme='middle-earth'] .queue-item.active { - background: rgba(212, 168, 32, 0.10); + background: rgba(212, 168, 32, 0.14); box-shadow: inset 2px 0 0 #d4a820; } -[data-theme='middle-earth'] .queue-item.active .queue-item-title { color: #2a1c0e; } +[data-theme='middle-earth'] .queue-item.active .queue-item-title { color: #f8e060; } [data-theme='middle-earth'] .queue-item.active .queue-item-artist, -[data-theme='middle-earth'] .queue-item.active .queue-item-duration { color: #8a6030; } +[data-theme='middle-earth'] .queue-item.active .queue-item-duration { color: #c8a060; } [data-theme='middle-earth'] .np-queue-item-active { color: #2a1c0e; text-shadow: 0 0 6px rgba(212,168,32,0.40); @@ -5320,6 +5320,11 @@ input[type="range"]:hover::-webkit-slider-thumb { text-shadow: 0 0 6px rgba(212,168,32,0.38); } +/* ── Queue tech bar: dark text on light parchment ── */ +[data-theme='middle-earth'] .queue-current-tech { + color: #3e2808; +} + /* ── Queue + Lyrics on dark sidebar ── */ [data-theme='middle-earth'] .queue-current-info h3 { color: #f0d880; } [data-theme='middle-earth'] .queue-current-sub { color: #d4a820; } @@ -8969,3 +8974,408 @@ input[type="range"]:hover::-webkit-slider-thumb { /* Connection indicators */ [data-theme='w11'] .connection-type, [data-theme='w11'] .connection-server { color: #797979; } + +/* ── Barb & Ken (Games) ─────────────────────────────────────── */ +[data-theme='barb-and-ken'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23FF1B8D%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Barbieland palette */ + --ctp-crust: #07000b; + --ctp-mantle: #110009; + --ctp-base: #1a000f; + --ctp-surface0: #2e0019; + --ctp-surface1: #450026; + --ctp-surface2: #5c0032; + --ctp-overlay0: #8c1455; + --ctp-overlay1: #b82070; + --ctp-overlay2: #d93d8a; + --ctp-text: #ffe6f3; + --ctp-subtext1: #ffb8da; + --ctp-subtext0: #ff80be; + + /* Barbie Pink spectrum */ + --ctp-mauve: #FF1B8D; + --ctp-pink: #FF69B4; + --ctp-flamingo: #FF1493; + --ctp-rosewater:#FFD6EC; + --ctp-lavender: #FFB3D9; + --ctp-maroon: #c4005e; + --ctp-red: #FF4466; + + /* Ken — powder blue as accent-2 */ + --ctp-blue: #89CFF0; + --ctp-sapphire: #5BC8F5; + --ctp-sky: #AAE8FF; + --ctp-teal: #70d8f2; + + --ctp-green: #a6e3a1; + --ctp-yellow: #FFE4A8; + --ctp-peach: #FFB8D0; + + --bg-app: #1a000f; + --bg-sidebar: #110009; + --bg-card: #2e0019; + --bg-hover: rgba(255, 27, 141, 0.12); + --bg-player: #07000b; + --bg-glass: rgba(26, 0, 15, 0.88); + + --accent: #FF1B8D; + --accent-dim: rgba(255, 27, 141, 0.15); + --accent-glow: rgba(255, 27, 141, 0.45); + --volume-accent: #89CFF0; + + --text-primary: #ffe6f3; + --text-secondary:#ffb8da; + --text-muted: #7a3055; + --border: rgba(255, 27, 141, 0.28); + --border-subtle: rgba(255, 27, 141, 0.12); + --border-dropdown: rgba(255, 27, 141, 0.3); + --shadow-dropdown: rgba(7, 0, 11, 0.9); + + --positive: #a6e3a1; + --warning: #FFE4A8; + --danger: #FF4466; + + /* Bubbly, soft rounding — very Barbie */ + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 20px; +} + +/* Polka-dot sidebar — Barbie's dreamhouse wallpaper */ +[data-theme='barb-and-ken'] .sidebar { + background: + radial-gradient(circle, rgba(255, 27, 141, 0.18) 2px, transparent 2px), + radial-gradient(circle, rgba(137, 207, 240, 0.10) 2px, transparent 2px), + linear-gradient(180deg, #110009 0%, #1a000f 100%); + background-size: 22px 22px, 22px 22px, 100% 100%; + background-position: 0 0, 11px 11px, 0 0; + border-right: 1px solid rgba(255, 27, 141, 0.35); +} + +/* Player bar — deep magenta gradient, hot pink top border */ +[data-theme='barb-and-ken'] .player-bar { + background: linear-gradient(180deg, #1a000f 0%, #07000b 100%); + border-top: 2px solid #FF1B8D; + box-shadow: 0 -6px 24px rgba(255, 27, 141, 0.18); +} + +/* Track name — glitter shimmer */ +@keyframes barbie-shimmer { + 0% { background-position: -200% center; } + 100% { background-position: 200% center; } +} + +[data-theme='barb-and-ken'] .player-track-name { + background: linear-gradient( + 90deg, + #FF1B8D 0%, + #FF69B4 25%, + #ffffff 50%, + #FF69B4 75%, + #FF1B8D 100% + ); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: barbie-shimmer 4s linear infinite; + font-weight: 700; +} + +/* Artist name — Ken blue */ +[data-theme='barb-and-ken'] .player-artist-name { + color: #89CFF0; +} + +/* Active nav — pink left border glow */ +[data-theme='barb-and-ken'] .nav-link.active { + background: linear-gradient(90deg, rgba(255, 27, 141, 0.22) 0%, transparent 100%); + color: #FF69B4 !important; + border-left: 3px solid #FF1B8D; + text-shadow: 0 0 8px rgba(255, 27, 141, 0.4); +} + +[data-theme='barb-and-ken'] .nav-link:hover:not(.active) { + background: rgba(255, 27, 141, 0.08); +} + +/* Content header — subtle pink rule */ +[data-theme='barb-and-ken'] .content-header { + border-bottom: 1px solid rgba(255, 27, 141, 0.2); + background: rgba(26, 0, 15, 0.6); + backdrop-filter: blur(12px); +} + +/* Primary buttons — hot pink gradient */ +[data-theme='barb-and-ken'] .btn-primary, +[data-theme='barb-and-ken'] .player-btn-primary, +[data-theme='barb-and-ken'] .hero-play-btn { + background: linear-gradient(135deg, #FF1B8D 0%, #FF69B4 100%); + box-shadow: 0 4px 16px rgba(255, 27, 141, 0.45); + color: #fff !important; + border: none; +} + +[data-theme='barb-and-ken'] .btn-primary:hover, +[data-theme='barb-and-ken'] .player-btn-primary:hover, +[data-theme='barb-and-ken'] .hero-play-btn:hover { + background: linear-gradient(135deg, #e5007a 0%, #FF1B8D 100%); + box-shadow: 0 6px 22px rgba(255, 27, 141, 0.60); +} + +/* Cards — pink border glow on hover */ +[data-theme='barb-and-ken'] .album-card, +[data-theme='barb-and-ken'] .artist-card, +[data-theme='barb-and-ken'] .card, +[data-theme='barb-and-ken'] .settings-card { + border: 1px solid rgba(255, 27, 141, 0.18); +} + +[data-theme='barb-and-ken'] .album-card:hover, +[data-theme='barb-and-ken'] .artist-card:hover { + border-color: rgba(255, 27, 141, 0.50); + box-shadow: 0 4px 20px rgba(255, 27, 141, 0.20); +} + +/* Queue active item */ +[data-theme='barb-and-ken'] .queue-item.active { + background: rgba(255, 27, 141, 0.08); + border-left: 3px solid #FF1B8D; +} + +[data-theme='barb-and-ken'] .queue-item.active .queue-item-title { + color: #FF69B4; +} + +[data-theme='barb-and-ken'] .queue-item.active .queue-item-artist, +[data-theme='barb-and-ken'] .queue-item.active .queue-item-duration { + color: #89CFF0; +} + +/* Track rows */ +[data-theme='barb-and-ken'] .track-row:hover, +[data-theme='barb-and-ken'] .queue-item:hover { + background: rgba(255, 27, 141, 0.07); +} + +/* Album detail header cover button — Ken blue glow on hover */ +[data-theme='barb-and-ken'] .album-detail-cover-btn:hover { + box-shadow: 0 0 24px rgba(137, 207, 240, 0.35); +} + +/* Star (favorite) icon — gold kept, but override to hot pink */ +[data-theme='barb-and-ken'] .color-star-active { + --color-star-active: #FF69B4; +} + +/* Scrollbar — thin hot pink */ +[data-theme='barb-and-ken'] ::-webkit-scrollbar { width: 5px; } +[data-theme='barb-and-ken'] ::-webkit-scrollbar-track { background: #07000b; } +[data-theme='barb-and-ken'] ::-webkit-scrollbar-thumb { + background: #FF1B8D; + border-radius: 3px; +} +[data-theme='barb-and-ken'] ::-webkit-scrollbar-thumb:hover { + background: #FF69B4; +} + +/* Connection indicators */ +[data-theme='barb-and-ken'] .connection-type, +[data-theme='barb-and-ken'] .connection-server { color: #7a3055; } + +/* ── Toy Tale (Movies) ──────────────────────────────────────── */ +[data-theme='toy-tale'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23FFD600%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Warm toy-box palette */ + --ctp-crust: #080603; + --ctp-mantle: #100d08; + --ctp-base: #1a1208; + --ctp-surface0: #2a1c10; + --ctp-surface1: #3a2818; + --ctp-surface2: #4a3422; + --ctp-overlay0: #7a5a35; + --ctp-overlay1: #9a7848; + --ctp-overlay2: #b89060; + --ctp-text: #f0e8d8; + --ctp-subtext1: #d8c8a8; + --ctp-subtext0: #c0a880; + + /* Woody gold */ + --ctp-mauve: #FFD600; + --ctp-yellow: #FFD600; + --ctp-peach: #FFA000; + --ctp-rosewater:#FFE0A0; + + /* Buzz Lightyear purple + green */ + --ctp-lavender: #9B72D6; + --ctp-blue: #7B4FD6; + --ctp-sapphire: #5c35a0; + --ctp-sky: #4FC3F7; + --ctp-teal: #4CAF50; + --ctp-green: #4CAF50; + + --ctp-pink: #FF6B9D; + --ctp-flamingo: #FF4488; + --ctp-maroon: #b71c1c; + --ctp-red: #e53935; + + --bg-app: #1a1208; + --bg-sidebar: #1e3a5f; /* Andy's sky-blue wallpaper */ + --bg-card: #2a1c10; + --bg-hover: rgba(255, 214, 0, 0.10); + --bg-player: #0d0a06; + --bg-glass: rgba(26, 18, 8, 0.90); + + --accent: #FFD600; + --accent-dim: rgba(255, 214, 0, 0.15); + --accent-glow: rgba(255, 214, 0, 0.40); + --volume-accent: #7B4FD6; /* Buzz purple */ + + --text-primary: #f0e8d8; + --text-secondary:#c8a878; + --text-muted: #6a5030; + --border: rgba(255, 214, 0, 0.22); + --border-subtle: rgba(255, 214, 0, 0.10); + --border-dropdown: rgba(255, 214, 0, 0.25); + --shadow-dropdown: rgba(8, 6, 3, 0.92); + + --positive: #4CAF50; + --warning: #FFA000; + --danger: #e53935; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; +} + +/* Sidebar — Andy's iconic cloud wallpaper in sky blue */ +[data-theme='toy-tale'] .sidebar { + background: + radial-gradient(ellipse 70px 42px at 18% 12%, rgba(255,255,255,0.14) 0%, transparent 100%), + radial-gradient(ellipse 45px 28px at 38% 18%, rgba(255,255,255,0.09) 0%, transparent 100%), + radial-gradient(ellipse 80px 50px at 72% 10%, rgba(255,255,255,0.12) 0%, transparent 100%), + radial-gradient(ellipse 55px 32px at 85% 20%, rgba(255,255,255,0.08) 0%, transparent 100%), + radial-gradient(ellipse 65px 40px at 12% 55%, rgba(255,255,255,0.10) 0%, transparent 100%), + radial-gradient(ellipse 48px 30px at 55% 68%, rgba(255,255,255,0.08) 0%, transparent 100%), + radial-gradient(ellipse 72px 44px at 80% 75%, rgba(255,255,255,0.11) 0%, transparent 100%), + linear-gradient(180deg, #1e3a5f 0%, #274d78 100%); + border-right: 1px solid rgba(255, 214, 0, 0.20); +} + +/* Nav links need lighter text on blue sidebar */ +[data-theme='toy-tale'] .nav-link { + color: rgba(255, 255, 255, 0.75); +} + +[data-theme='toy-tale'] .nav-link:hover:not(.active) { + background: rgba(255, 255, 255, 0.10); + color: #fff; +} + +[data-theme='toy-tale'] .nav-link.active { + background: rgba(255, 214, 0, 0.20); + border-left: 3px solid #FFD600; + color: #FFD600 !important; + text-shadow: 0 0 8px rgba(255, 214, 0, 0.4); +} + +[data-theme='toy-tale'] .sidebar-logo, +[data-theme='toy-tale'] .sidebar-title { + color: #fff; +} + +/* Player bar — dark toy-chest wood */ +[data-theme='toy-tale'] .player-bar { + background: linear-gradient(180deg, #1a1208 0%, #0d0a06 100%); + border-top: 2px solid #FFD600; + box-shadow: 0 -6px 20px rgba(255, 214, 0, 0.10); +} + +/* Track name — Woody sheriff star gold */ +[data-theme='toy-tale'] .player-track-name { + color: #FFD600; + text-shadow: 0 0 10px rgba(255, 214, 0, 0.45); + font-weight: 700; +} + +/* Artist name — warm cowboy tan */ +[data-theme='toy-tale'] .player-artist-name { + color: #c8a060; +} + +/* Content header */ +[data-theme='toy-tale'] .content-header { + border-bottom: 1px solid rgba(255, 214, 0, 0.18); +} + +/* Primary buttons — Woody yellow */ +[data-theme='toy-tale'] .btn-primary, +[data-theme='toy-tale'] .player-btn-primary, +[data-theme='toy-tale'] .hero-play-btn { + background: linear-gradient(135deg, #FFD600 0%, #FFA000 100%); + color: #1a1208 !important; + font-weight: 700; + box-shadow: 0 4px 14px rgba(255, 214, 0, 0.35); + border: none; +} + +[data-theme='toy-tale'] .btn-primary:hover, +[data-theme='toy-tale'] .player-btn-primary:hover, +[data-theme='toy-tale'] .hero-play-btn:hover { + background: linear-gradient(135deg, #ffe033 0%, #FFD600 100%); + box-shadow: 0 6px 20px rgba(255, 214, 0, 0.50); +} + +/* Cards */ +[data-theme='toy-tale'] .album-card, +[data-theme='toy-tale'] .artist-card, +[data-theme='toy-tale'] .card, +[data-theme='toy-tale'] .settings-card { + border: 1px solid rgba(255, 214, 0, 0.16); +} + +[data-theme='toy-tale'] .album-card:hover, +[data-theme='toy-tale'] .artist-card:hover { + border-color: rgba(255, 214, 0, 0.42); + box-shadow: 0 4px 18px rgba(255, 214, 0, 0.14); +} + +/* Track rows */ +[data-theme='toy-tale'] .track-row:hover, +[data-theme='toy-tale'] .queue-item:hover { + background: rgba(255, 214, 0, 0.06); +} + +/* Queue active item — Buzz purple accent */ +[data-theme='toy-tale'] .queue-item.active { + background: rgba(123, 79, 214, 0.12); + border-left: 3px solid #7B4FD6; +} + +[data-theme='toy-tale'] .queue-item.active .queue-item-title { + color: #FFD600; +} + +[data-theme='toy-tale'] .queue-item.active .queue-item-artist, +[data-theme='toy-tale'] .queue-item.active .queue-item-duration { + color: #9B72D6; +} + +/* Scrollbar — Woody brown */ +[data-theme='toy-tale'] ::-webkit-scrollbar { width: 6px; } +[data-theme='toy-tale'] ::-webkit-scrollbar-track { background: #0d0a06; } +[data-theme='toy-tale'] ::-webkit-scrollbar-thumb { + background: #7a5a35; + border-radius: 3px; +} +[data-theme='toy-tale'] ::-webkit-scrollbar-thumb:hover { + background: #FFD600; +} + +/* Connection indicators */ +[data-theme='toy-tale'] .connection-type, +[data-theme='toy-tale'] .connection-server { color: #6a5030; } diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts index 193176e3..5589f57d 100644 --- a/src/utils/imageCache.ts +++ b/src/utils/imageCache.ts @@ -1,3 +1,5 @@ +import { useAuthStore } from '../store/authStore'; + const DB_NAME = 'psysonic-img-cache'; const STORE_NAME = 'images'; const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -25,7 +27,7 @@ function releaseFetchSlot(): void { if (next) { activeFetches++; next(); } } -function evictIfNeeded(): void { +function evictMemoryIfNeeded(): void { while (objectUrlCache.size > MAX_MEMORY_CACHE) { const oldestKey = objectUrlCache.keys().next().value; if (!oldestKey) break; @@ -73,6 +75,48 @@ async function getBlob(key: string): Promise { } } +/** Evicts oldest IDB entries until total blob size is below maxBytes. Fire-and-forget. */ +async function evictDiskIfNeeded(maxBytes: number): Promise { + try { + const database = await openDB(); + const entries: Array<{ key: string; timestamp: number; size: number }> = await new Promise(resolve => { + const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll(); + req.onsuccess = () => { + resolve( + (req.result ?? []).map((e: { key: string; timestamp: number; blob: Blob }) => ({ + key: e.key, + timestamp: e.timestamp, + size: e.blob?.size ?? 0, + })), + ); + }; + req.onerror = () => resolve([]); + }); + + let total = entries.reduce((acc, e) => acc + e.size, 0); + if (total <= maxBytes) return; + + // Oldest first + entries.sort((a, b) => a.timestamp - b.timestamp); + + const tx = database.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const entry of entries) { + if (total <= maxBytes) break; + store.delete(entry.key); + // Also purge from memory cache + const objUrl = objectUrlCache.get(entry.key); + if (objUrl) { + URL.revokeObjectURL(objUrl); + objectUrlCache.delete(entry.key); + } + total -= entry.size; + } + } catch { + // Ignore + } +} + async function putBlob(key: string, blob: Blob): Promise { try { const database = await openDB(); @@ -82,11 +126,50 @@ async function putBlob(key: string, blob: Blob): Promise { tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }); + // Enforce disk limit after write (fire-and-forget) + const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024; + evictDiskIfNeeded(maxBytes); } catch { // Ignore write errors } } +/** Returns the total size in bytes of all blobs stored in IndexedDB. */ +export async function getImageCacheSize(): Promise { + try { + const database = await openDB(); + return new Promise(resolve => { + const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll(); + req.onsuccess = () => { + const entries: Array<{ blob: Blob }> = req.result ?? []; + resolve(entries.reduce((acc, e) => acc + (e.blob?.size ?? 0), 0)); + }; + req.onerror = () => resolve(0); + }); + } catch { + return 0; + } +} + +/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */ +export async function clearImageCache(): Promise { + for (const url of objectUrlCache.values()) { + URL.revokeObjectURL(url); + } + objectUrlCache.clear(); + try { + const database = await openDB(); + await new Promise(resolve => { + const tx = database.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + // Ignore + } +} + /** * Returns a cached object URL for an image. * @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params). @@ -104,7 +187,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise< if (blob) { const objUrl = URL.createObjectURL(blob); objectUrlCache.set(cacheKey, objUrl); - evictIfNeeded(); + evictMemoryIfNeeded(); return objUrl; } @@ -114,10 +197,10 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise< const resp = await fetch(fetchUrl); if (!resp.ok) return fetchUrl; const newBlob = await resp.blob(); - putBlob(cacheKey, newBlob); // fire-and-forget + putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction) const objUrl = URL.createObjectURL(newBlob); objectUrlCache.set(cacheKey, objUrl); - evictIfNeeded(); + evictMemoryIfNeeded(); return objUrl; } catch { return fetchUrl;