From 2ba7845c796994d05de4fb1c5bbbb2c49534f20e Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 20 Mar 2026 18:35:26 +0100 Subject: [PATCH] feat: Last.fm beta, Similar Artists, Statistics Last.fm stats, TooltipPortal, CustomSelect, Psychowave theme (v1.7.0) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 5 + .gitignore | 5 + CHANGELOG.md | 48 ++++ CLAUDE.md | 2 +- package.json | 2 +- packages/aur/PKGBUILD | 2 +- src-tauri/Cargo.lock | 9 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/lib.rs | 57 +++++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 5 +- src/api/lastfm.ts | 290 +++++++++++++++++++++++ src/components/ConnectionIndicator.tsx | 2 +- src/components/ContextMenu.tsx | 35 ++- src/components/CustomSelect.tsx | 115 +++++++++ src/components/Equalizer.tsx | 30 ++- src/components/FullscreenPlayer.tsx | 3 - src/components/LastfmIcon.tsx | 7 + src/components/PlayerBar.tsx | 16 +- src/components/Sidebar.tsx | 25 +- src/components/TooltipPortal.tsx | 94 ++++++++ src/i18n.ts | 72 +++++- src/pages/ArtistDetail.tsx | 66 +++++- src/pages/NowPlaying.tsx | 15 +- src/pages/Settings.tsx | 182 +++++++++++---- src/pages/Statistics.tsx | 190 ++++++++++++--- src/store/authStore.ts | 8 + src/store/playerStore.ts | 86 ++++++- src/store/themeStore.ts | 2 +- src/styles/components.css | 309 ++++++++----------------- src/styles/theme.css | 55 +++++ 31 files changed, 1379 insertions(+), 365 deletions(-) create mode 100644 src/api/lastfm.ts create mode 100644 src/components/CustomSelect.tsx create mode 100644 src/components/LastfmIcon.tsx create mode 100644 src/components/TooltipPortal.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cbbb4a4..44b23b7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,6 +100,8 @@ jobs: - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }} + VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }} with: releaseId: ${{ needs.create-release.outputs.release_id }} args: ${{ matrix.settings.args }} @@ -131,6 +133,9 @@ jobs: run: npm install - name: build + env: + VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }} + VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }} run: npm run tauri:build -- --bundles deb,rpm - name: upload Linux artifacts diff --git a/.gitignore b/.gitignore index 398d1732..1a5289ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +# Environment variables (API keys) +.env +.env.local +.env.*.local + # Node node_modules dist diff --git a/CHANGELOG.md b/CHANGELOG.md index d27b803e..d10e1e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ 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.7.0] - 2026-03-20 + +### Added + +#### Last.fm Integration *(Beta)* +- **Direct Last.fm scrobbling**: Tracks are scrobbled directly via the Last.fm API at 50% playback — no longer routed through Navidrome. Configure in Settings → Server with your Last.fm username and password. +- **Now Playing updates**: Last.fm receives the currently playing track in real time. +- **Love / Unlove**: Heart button in the Now Playing page and player bar syncs the loved state with Last.fm instantly. +- **Last.fm profile badge** in Settings → Server: shows your scrobble count and member since year once connected. +- ⚠️ **This feature is in beta.** Session management and edge cases are still being refined. + +#### Similar Artists +- Artist detail pages now show a **Similar Artists** section below Top Tracks, sourced from Last.fm and filtered to artists actually present in your library. Shown as chip buttons — click to navigate directly to that artist's page. +- Requires Last.fm to be configured. Hidden when Last.fm is not connected or no library matches are found. + +#### Statistics — Last.fm Stats +- New **Last.fm Stats** section on the Statistics page (requires Last.fm): top artists, albums, and tracks with proportional play-count bars. +- **Period filter**: switch between Last 7 Days, 1 Month, 3 Months, 6 Months, 12 Months, and Overall. +- **Recent Scrobbles**: last 20 scrobbled tracks with relative timestamps and a "Now Playing" badge for the currently active entry. +- **Genre Distribution removed**: replaced by the Last.fm stats sections. + +#### Psychowave Theme *(Work in Progress)* +- New **Psychowave** theme: a deep purple/violet dark theme inspired by synthwave and retrowave aesthetics. +- ⚠️ **Still in active development** — colors and details will continue to be refined in upcoming releases. + +#### Tooltip System — TooltipPortal +- All tooltips now use a **React portal** rendered into `document.body` at `z-index: 99999`. Replaces the previous CSS `::after` pseudo-element system. +- Fixes tooltip clipping inside `overflow: hidden` containers (player bar, queue panel, EQ). +- Fixes black OS-native tooltip boxes that appeared on native `title=` attributes — all converted to `data-tooltip`. +- Smart edge detection: tooltip flips position automatically when it would overflow the viewport. + +#### Custom Select Dropdowns +- **Theme**, **Language**, and **EQ preset** selectors are now rendered as styled portal dropdowns — no more unstyled native ` applyPreset(e.target.value)} - > - {activePreset === null && } - - {BUILTIN_PRESETS.map(p => )} - - {customPresets.length > 0 && ( - - {customPresets.map(p => )} - - )} - + onChange={v => applyPreset(v)} + options={[ + ...(activePreset === null ? [{ value: '__custom__', label: t('settings.eqPresetCustom'), disabled: true }] : []), + ...BUILTIN_PRESETS.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetBuiltin') })), + ...customPresets.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetCustomGroup') })), + ]} + /> {isCustomSaved && ( - )} - - diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 9c6cc798..3cbae117 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -193,9 +193,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { <>
- + onChange={v => i18n.changeLanguage(v)} + options={[ + { value: 'en', label: t('settings.languageEn') }, + { value: 'de', label: t('settings.languageDe') }, + ]} + />
diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index a8ac5ec9..49a52b1a 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -1,34 +1,87 @@ import React, { useEffect, useState } from 'react'; -import { getAlbumList, getArtists, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic'; +import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic'; import AlbumRow from '../components/AlbumRow'; import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/authStore'; +import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm'; + +function relativeTime(timestamp: number, t: (key: string, opts?: object) => string): string { + const diff = Math.floor(Date.now() / 1000) - timestamp; + if (diff < 60) return t('statistics.lfmJustNow'); + if (diff < 3600) return t('statistics.lfmMinutesAgo', { n: Math.floor(diff / 60) }); + if (diff < 86400) return t('statistics.lfmHoursAgo', { n: Math.floor(diff / 3600) }); + return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) }); +} + +const PERIODS: { key: LastfmPeriod; label: string }[] = [ + { key: '7day', label: 'lfmPeriod7day' }, + { key: '1month', label: 'lfmPeriod1month' }, + { key: '3month', label: 'lfmPeriod3month' }, + { key: '6month', label: 'lfmPeriod6month' }, + { key: '12month', label: 'lfmPeriod12month' }, + { key: 'overall', label: 'lfmPeriodOverall' }, +]; export default function Statistics() { const { t } = useTranslation(); + const { lastfmSessionKey, lastfmUsername } = useAuthStore(); const [recent, setRecent] = useState([]); const [frequent, setFrequent] = useState([]); const [highest, setHighest] = useState([]); - const [genres, setGenres] = useState([]); const [artistCount, setArtistCount] = useState(null); + const [totalSongs, setTotalSongs] = useState(null); + const [totalAlbums, setTotalAlbums] = useState(null); const [loading, setLoading] = useState(true); + const [lfmPeriod, setLfmPeriod] = useState('1month'); + const [lfmTopArtists, setLfmTopArtists] = useState([]); + const [lfmTopAlbums, setLfmTopAlbums] = useState([]); + const [lfmTopTracks, setLfmTopTracks] = useState([]); + const [lfmLoading, setLfmLoading] = useState(false); + const [lfmRecentTracks, setLfmRecentTracks] = useState([]); + const [lfmRecentLoading, setLfmRecentLoading] = useState(false); + useEffect(() => { Promise.all([ getAlbumList('recent', 20).catch(() => []), getAlbumList('frequent', 12).catch(() => []), getAlbumList('highest', 12).catch(() => []), - getGenres().catch(() => []), getArtists().catch(() => []), - ]).then(([rc, fr, hi, g, a]) => { + getGenres().catch(() => []), + ]).then(([rc, fr, hi, a, g]) => { setRecent(rc); setFrequent(fr); setHighest(hi); - setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20)); setArtistCount(a.length); + setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0)); + setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0)); setLoading(false); }).catch(() => setLoading(false)); }, []); + useEffect(() => { + if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return; + setLfmRecentLoading(true); + lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20) + .then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); }) + .catch(() => setLfmRecentLoading(false)); + }, [lastfmSessionKey, lastfmUsername]); + + useEffect(() => { + if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return; + setLfmLoading(true); + Promise.all([ + lastfmGetTopArtists(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), + lastfmGetTopAlbums(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), + lastfmGetTopTracks(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), + ]).then(([artists, albums, tracks]) => { + setLfmTopArtists(artists); + setLfmTopAlbums(albums); + setLfmTopTracks(tracks); + setLfmLoading(false); + }).catch(() => setLfmLoading(false)); + }, [lfmPeriod, lastfmSessionKey, lastfmUsername]); + const loadMore = async ( type: 'frequent' | 'highest', currentList: SubsonicAlbum[], @@ -43,15 +96,10 @@ export default function Statistics() { } }; - const totalSongs = genres.reduce((acc, g) => acc + g.songCount, 0); - const totalAlbums = genres.reduce((acc, g) => acc + g.albumCount, 0); - const maxGenreCount = Math.max(...genres.map(g => g.songCount), 1); - const stats = [ { label: t('statistics.statArtists'), value: artistCount }, - { label: t('statistics.statAlbums'), value: totalAlbums || null }, - { label: t('statistics.statSongs'), value: totalSongs || null }, - { label: t('statistics.statGenres'), value: genres.length || null }, + { label: t('statistics.statAlbums'), value: totalAlbums }, + { label: t('statistics.statSongs'), value: totalSongs }, ]; return ( @@ -90,29 +138,105 @@ export default function Statistics() { moreText={t('statistics.loadMore')} /> - {genres.length > 0 && ( -
-

{t('statistics.genreDistribution')}

-
- {genres.map(genre => ( -
-
- {genre.value} - - {t('statistics.genreSongs', { count: genre.songCount })} - {' · '} - {t('statistics.genreAlbums', { count: genre.albumCount })} + {/* Last.fm Stats */} + {lastfmIsConfigured() && ( +
+
+

{t('statistics.lfmTitle')}

+ {lastfmSessionKey && ( +
+ {PERIODS.map(p => ( + + ))} +
+ )} +
+ + {!lastfmSessionKey ? ( +

{t('statistics.lfmNotConnected')}

+ ) : lfmLoading ? ( +
+
+
+ ) : ( +
+ {([ + { label: t('statistics.lfmTopArtists'), items: lfmTopArtists.map(a => ({ primary: a.name, secondary: null, playcount: a.playcount })) }, + { label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist, playcount: a.playcount })) }, + { label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist, playcount: tr.playcount })) }, + ] as { label: string; items: { primary: string; secondary: string | null; playcount: string }[] }[]).map(col => { + const max = Math.max(...col.items.map(it => Number(it.playcount)), 1); + return ( +
+

+ {col.label} +

+
    + {col.items.map((it, i) => ( +
  1. +
    + + {i + 1} + +
    +
    {it.primary}
    + {it.secondary && ( +
    {it.secondary}
    + )} +
    + {Number(it.playcount).toLocaleString()} +
    +
    +
    +
    +
  2. + ))} +
+
+ ); + })} +
+ )} +
+ )} + + {/* Recent Scrobbles */} + {lastfmIsConfigured() && lastfmSessionKey && ( +
+

{t('statistics.lfmRecentTracks')}

+ {lfmRecentLoading ? ( +
+
+
+ ) : ( +
+ {lfmRecentTracks.map((track, i) => ( +
+
+
+ {track.name} + {track.nowPlaying && ( + {t('statistics.lfmNowPlaying')} + )} +
+
+ {track.artist}{track.album ? ` · ${track.album}` : ''} +
+
+ + {track.nowPlaying ? '' : track.timestamp ? relativeTime(track.timestamp, t) : ''}
-
-
-
-
- ))} -
+ ))} +
+ )}
)} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index da8173e5..8efeca80 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -47,6 +47,8 @@ interface AuthState { setConnecting: (v: boolean) => void; setConnectionError: (e: string | null) => void; setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void; + connectLastfm: (sessionKey: string, username: string) => void; + disconnectLastfm: () => void; setMinimizeToTray: (v: boolean) => void; setScrobblingEnabled: (v: boolean) => void; setMaxCacheMb: (v: number) => void; @@ -126,6 +128,12 @@ export const useAuthStore = create()( setLastfm: (apiKey, apiSecret, sessionKey, username) => set({ lastfmApiKey: apiKey, lastfmApiSecret: apiSecret, lastfmSessionKey: sessionKey, lastfmUsername: username }), + connectLastfm: (sessionKey, username) => + set({ lastfmSessionKey: sessionKey, lastfmUsername: username }), + + disconnectLastfm: () => + set({ lastfmSessionKey: '', lastfmUsername: '' }), + setMinimizeToTray: (v) => set({ minimizeToTray: v }), setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }), setMaxCacheMb: (v) => set({ maxCacheMb: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index d55eb95f..3d4452d8 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -2,7 +2,8 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { buildStreamUrl, getPlayQueue, savePlayQueue, SubsonicSong, reportNowPlaying, scrobbleSong } from '../api/subsonic'; +import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, SubsonicSong } from '../api/subsonic'; +import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; export interface Track { @@ -55,6 +56,8 @@ interface PlayerState { currentTime: number; volume: number; scrobbled: boolean; + lastfmLoved: boolean; + lastfmLovedCache: Record; playTrack: (track: Track, queue?: Track[]) => void; pause: () => void; @@ -83,6 +86,11 @@ interface PlayerState { removeTrack: (index: number) => void; shuffleQueue: () => void; + toggleLastfmLove: () => void; + setLastfmLoved: (v: boolean) => void; + setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void; + syncLastfmLovedTracks: () => Promise; + initializeFromServerQueue: () => Promise; contextMenu: { @@ -143,11 +151,13 @@ function handleAudioProgress(current_time: number, duration: number) { const progress = current_time / dur; usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 }); - // Scrobble at 50% + // Scrobble at 50% directly via Last.fm if (progress >= 0.5 && !store.scrobbled) { usePlayerStore.setState({ scrobbled: true }); - const { scrobblingEnabled } = useAuthStore.getState(); - if (scrobblingEnabled) scrobbleSong(track.id, Date.now()); + const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); + if (scrobblingEnabled && lastfmSessionKey) { + lastfmScrobble(track, Date.now(), lastfmSessionKey); + } } // Gapless preload: buffer next track when 30s remain @@ -205,6 +215,9 @@ export function initAudioListeners(): () => void { listen('audio:error', ({ payload }) => handleAudioError(payload)), ]; + // Sync Last.fm loved tracks cache on startup. + usePlayerStore.getState().syncLastfmLovedTracks(); + // Initial sync of crossfade settings to Rust audio engine on startup. const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState(); invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {}); @@ -237,6 +250,8 @@ export const usePlayerStore = create()( currentTime: 0, volume: 0.8, scrobbled: false, + lastfmLoved: false, + lastfmLovedCache: {}, isQueueVisible: true, isFullscreenOpen: false, repeatMode: 'off', @@ -253,6 +268,55 @@ export const usePlayerStore = create()( setQueueVisible: (v: boolean) => set({ isQueueVisible: v }), toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })), + toggleLastfmLove: () => { + const { currentTrack, lastfmLoved } = get(); + const { lastfmSessionKey } = useAuthStore.getState(); + if (!currentTrack || !lastfmSessionKey) return; + const newLoved = !lastfmLoved; + const cacheKey = `${currentTrack.title}::${currentTrack.artist}`; + set(s => ({ lastfmLoved: newLoved, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: newLoved } })); + if (newLoved) { + lastfmLoveTrack(currentTrack, lastfmSessionKey); + } else { + lastfmUnloveTrack(currentTrack, lastfmSessionKey); + } + }, + + setLastfmLoved: (v) => { + const { currentTrack } = get(); + if (currentTrack) { + const cacheKey = `${currentTrack.title}::${currentTrack.artist}`; + set(s => ({ lastfmLoved: v, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v } })); + } else { + set({ lastfmLoved: v }); + } + }, + + syncLastfmLovedTracks: async () => { + const { lastfmSessionKey, lastfmUsername } = useAuthStore.getState(); + if (!lastfmSessionKey || !lastfmUsername) return; + const tracks = await lastfmGetAllLovedTracks(lastfmUsername, lastfmSessionKey); + const newCache: Record = {}; + for (const t of tracks) newCache[`${t.title}::${t.artist}`] = true; + // Merge with existing cache (local likes take precedence) + set(s => ({ lastfmLovedCache: { ...newCache, ...s.lastfmLovedCache } })); + // Update current track's loved state if it's in the new cache + const { currentTrack } = get(); + if (currentTrack) { + const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false; + set({ lastfmLoved: loved }); + } + }, + + setLastfmLovedForSong: (title, artist, v) => { + const cacheKey = `${title}::${artist}`; + const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist; + set(s => ({ + lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v }, + ...(isCurrentTrack ? { lastfmLoved: v } : {}), + })); + }, + toggleRepeat: () => set(state => { const modes = ['off', 'all', 'one'] as const; return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] }; @@ -285,6 +349,7 @@ export const usePlayerStore = create()( buffered: 0, currentTime: 0, scrobbled: false, + lastfmLoved: false, isPlaying: true, // optimistic — reverted on error }); @@ -310,7 +375,19 @@ export const usePlayerStore = create()( }, 500); }); + // Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm reportNowPlaying(track.id); + const { scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState(); + if (lfmKey) { + if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey); + lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => { + const cacheKey = `${track.title}::${track.artist}`; + usePlayerStore.setState(s => ({ + lastfmLoved: loved, + lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved }, + })); + }); + } syncQueueToServer(newQueue, track, 0); }, @@ -521,6 +598,7 @@ export const usePlayerStore = create()( queue: state.queue, queueIndex: state.queueIndex, currentTime: state.currentTime, + lastfmLovedCache: state.lastfmLovedCache, } as Partial), } ) diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 79ef0c51..dac0eb80 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora'; +type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave'; interface ThemeState { theme: Theme; diff --git a/src/styles/components.css b/src/styles/components.css index 09e21525..b173571b 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1716,57 +1716,6 @@ } } -/* ── Drifting color orbs ── */ -@keyframes orb-a { - 0% { - transform: translate(0px, 0px) scale(1); - } - - 33% { - transform: translate(90px, -50px) scale(1.12); - } - - 66% { - transform: translate(-40px, 70px) scale(0.94); - } - - 100% { - transform: translate(0px, 0px) scale(1); - } -} - -@keyframes orb-b { - 0% { - transform: translate(0px, 0px) scale(1); - } - - 33% { - transform: translate(-70px, 40px) scale(1.08); - } - - 66% { - transform: translate(50px, -60px) scale(1.14); - } - - 100% { - transform: translate(0px, 0px) scale(1); - } -} - -@keyframes orb-c { - 0% { - transform: translate(0px, 0px) scale(1); - } - - 50% { - transform: translate(60px, 50px) scale(0.9); - } - - 100% { - transform: translate(0px, 0px) scale(1); - } -} - /* ── Cover breathing ── */ @keyframes cover-breathe { @@ -1781,38 +1730,23 @@ } @keyframes ken-burns { - 0% { - transform: scale(1.08) translate(0%, 0%); - } - - 25% { - transform: scale(1.12) translate(-1.5%, 1%); - } - - 50% { - transform: scale(1.10) translate(1%, -1.5%); - } - - 75% { - transform: scale(1.13) translate(1.5%, 0.5%); - } - - 100% { - transform: scale(1.08) translate(0%, 0%); - } + 0% { transform: scale(1.1) translate(-8%, -8%); } + 25% { transform: scale(1.15) translate(8%, -8%); } + 50% { transform: scale(1.1) translate(8%, 8%); } + 75% { transform: scale(1.15) translate(-8%, 8%); } + 100% { transform: scale(1.1) translate(-8%, -8%); } } /* ── Blurred background ── */ .fs-bg { position: absolute; - inset: -15%; + inset: -30%; background-size: cover; - background-position: top center; + background-position: center center; filter: blur(6px) brightness(0.25) saturate(1.6); - animation: ken-burns 40s ease-in-out infinite; - transform: scale(1.2); + animation: ken-burns 90s ease-in-out infinite; z-index: 0; - will-change: opacity; + will-change: transform; pointer-events: none; transition: opacity 700ms ease; } @@ -1825,45 +1759,6 @@ pointer-events: none; } -/* ── Drifting color orbs ── */ -.fs-orb { - position: absolute; - border-radius: 50%; - filter: blur(110px); - opacity: 0.22; - pointer-events: none; - z-index: 0; -} - -.fs-orb-1 { - background: var(--ctp-mauve); - width: 700px; - height: 700px; - top: -220px; - left: -180px; - animation: orb-a 20s ease-in-out infinite; -} - -.fs-orb-2 { - background: var(--ctp-blue); - width: 600px; - height: 600px; - bottom: -180px; - right: -120px; - animation: orb-b 26s ease-in-out infinite; - animation-delay: -9s; -} - -.fs-orb-3 { - background: var(--ctp-lavender); - width: 480px; - height: 480px; - top: 35%; - right: 5%; - animation: orb-c 17s ease-in-out infinite; - animation-delay: -14s; -} - /* ── Close button ── */ .fs-close { position: absolute; @@ -2272,71 +2167,11 @@ } /* ─ CSS Tooltips ─ */ +/* Tooltips are handled by TooltipPortal (React portal) — no CSS pseudo-elements needed */ [data-tooltip] { position: relative; } -[data-tooltip]::before, -[data-tooltip]::after { - position: absolute; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; - pointer-events: none; - z-index: 9999; -} - -[data-tooltip]::before { - content: ''; - border: 5px solid transparent; - border-top-color: var(--border-subtle); - bottom: 100%; - left: 50%; - transform: translateX(-50%); -} - -/* Inner triangle for the tooltip arrow to match background */ -[data-tooltip]::after { - content: attr(data-tooltip); - background: var(--bg-card); - color: var(--text-primary); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - font-size: 12px; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - white-space: nowrap; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); - font-weight: 500; - border: 1px solid var(--border-subtle); -} - -[data-tooltip]:hover::before, -[data-tooltip]:hover::after { - opacity: 1; - visibility: visible; -} - -[data-tooltip-wrap]::after { - white-space: pre-line; - max-width: 220px; - text-align: left; -} - -/* Modifiers for position */ -[data-tooltip][data-tooltip-pos="bottom"]::before { - top: 100%; - bottom: auto; - border-top-color: transparent; - border-bottom-color: var(--border-subtle); -} - -[data-tooltip][data-tooltip-pos="bottom"]::after { - top: calc(100% + 6px); - bottom: auto; -} - /* ─ Playlists Page ─ */ .playlist-page-header { @@ -2479,7 +2314,7 @@ .stats-overview { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: var(--space-4); } @@ -2746,47 +2581,6 @@ background: linear-gradient(to bottom, transparent 40%, var(--bg-app) 100%); } -/* Orbs — will-change promotes each to its own compositor layer so - the blur is composited on the GPU and doesn't repaint on every frame */ -.np-orb { - position: absolute; - border-radius: 50%; - filter: blur(70px); - opacity: 0.3; - pointer-events: none; - z-index: 0; - will-change: transform; -} - -.np-orb-1 { - background: var(--ctp-mauve); - width: 400px; - height: 400px; - top: -120px; - left: -80px; - animation: orb-a 18s ease-in-out infinite; -} - -.np-orb-2 { - background: var(--ctp-blue); - width: 320px; - height: 320px; - bottom: -80px; - right: -60px; - animation: orb-b 24s ease-in-out infinite; - animation-delay: -9s; -} - -.np-orb-3 { - background: var(--ctp-lavender); - width: 260px; - height: 260px; - top: 30%; - right: 10%; - animation: orb-c 15s ease-in-out infinite; - animation-delay: -5s; -} - .np-hero-content { position: relative; z-index: 1; @@ -3208,6 +3002,87 @@ min-width: 140px; } +/* ─ Custom Select ─ */ +.custom-select-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; +} +.custom-select-trigger:hover { + border-color: var(--accent); + background: var(--bg-hover); +} +.custom-select-trigger:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.custom-select-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.custom-select-chevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.2s ease; +} +.custom-select-chevron.open { + transform: rotate(180deg); +} +.custom-select-dropdown { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + overflow-y: auto; + overscroll-behavior: contain; +} +.custom-select-option { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + color: var(--text-primary); + cursor: pointer; + transition: background 0.1s; +} +.custom-select-option:hover { + background: var(--bg-hover); +} +.custom-select-option.selected { + color: var(--accent); + font-weight: 600; + background: color-mix(in srgb, var(--accent) 8%, transparent); +} +.custom-select-option.disabled { + color: var(--text-muted); + cursor: default; +} +.custom-select-group-label { + padding: 0.375rem 0.75rem 0.25rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + border-top: 1px solid var(--border-subtle); + margin-top: 0.25rem; +} +.custom-select-group-label:first-child { + border-top: none; + margin-top: 0; +} + .eq-ctrl-btn { background: var(--bg-hover); border: 1px solid var(--border); diff --git a/src/styles/theme.css b/src/styles/theme.css index 1461b843..993b5d31 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -422,6 +422,61 @@ --danger: #bf616a; } +/* ─── Psychowave — Synthwave / Retrowave ─── */ +[data-theme='psychowave'] { + 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%23c9a8ff%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"); + + /* ── Palette ── */ + --ctp-crust: #120720; + --ctp-mantle: #160a28; + --ctp-base: #1c0b35; + --ctp-surface0: #2a1050; + --ctp-surface1: #381860; + --ctp-surface2: #4a2878; + --ctp-overlay0: #5e3d90; + --ctp-overlay1: #7b50b8; + --ctp-overlay2: #9868d8; + /* Soft lavender text */ + --ctp-text: #f0e6ff; + --ctp-subtext1: #c9a8ff; + --ctp-subtext0: #a882e0; + /* Neon accents */ + --ctp-mauve: #ff2d78; /* hot pink — primary accent & button gradient start */ + --ctp-lavender: #bf5fff; /* electric purple — button gradient end */ + --ctp-pink: #ff2d78; + --ctp-flamingo: #ff6b9d; + --ctp-rosewater: #ff8fc0; + --ctp-blue: #00d4ff; /* electric cyan */ + --ctp-sapphire: #007aff; + --ctp-sky: #00bfff; + --ctp-teal: #00e5cc; + --ctp-green: #39ff14; /* neon green */ + --ctp-yellow: #ffd700; /* neon gold */ + --ctp-peach: #ff8c42; + --ctp-maroon: #ff4d6d; + --ctp-red: #ff2d55; + + /* ── Semantic tokens ── */ + --bg-app: #1c0b35; + --bg-sidebar: #160a28; + --bg-card: #2a1050; + --bg-hover: #381860; + --bg-player: #120720; + --bg-glass: rgba(28, 11, 53, 0.82); + --accent: #ff2d78; + --accent-dim: rgba(255, 45, 120, 0.15); + --accent-glow: rgba(255, 45, 120, 0.45); + --text-primary: #f0e6ff; + --text-secondary:#c9a8ff; + --text-muted: #9b72d0; + --border: #4a2878; + --border-subtle: #2a1050; + --positive: #39ff14; + --warning: #ffd700; + --danger: #ff2d55; +} + /* ─── Global Base Settings ─── */ :root { /* Typography */