Compare commits

..

8 Commits

Author SHA1 Message Date
Psychotoxical 409c20a79e Update README.md 2026-03-13 20:13:57 +01:00
Psychotoxical 3384db76cd docs: remove Security section from README 2026-03-13 20:11:53 +01:00
Psychotoxical e889a76f5f docs: update README with new features and known limitations 2026-03-13 20:11:00 +01:00
Psychotoxical eb011bdfdf Update README.md 2026-03-13 20:09:07 +01:00
Psychotoxical 1d23b21f6f Update README.md 2026-03-13 20:08:29 +01:00
Psychotoxical 5528123193 feat: Update Notification system and UI refinements (v1.0.7) 2026-03-13 19:31:44 +01:00
Psychotoxical 85823ff4c4 feat: Nord themes, Wayland compatibility, and stability fixes (v1.0.6) 2026-03-13 18:59:32 +01:00
Psychotoxical e36a81f847 Update README.md 2026-03-12 22:30:41 +01:00
22 changed files with 614 additions and 111 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ jobs:
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
echo "Patching: $APPIMAGE"
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1' squashfs-root/AppRun
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
-O appimagetool
chmod +x appimagetool
+21
View File
@@ -5,6 +5,27 @@ 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.7] - 2026-03-13
### Added
- **Update Notifications**: Integrated a native update check system in the sidebar that notifies you when a new version is available on GitHub.
- **Improved Settings**: Refined layout and styling for a cleaner settings experience.
### Fixed
- **UI/UX Refinements**: Polished sidebar animations and layout for better visual consistency.
- **i18n**: Added missing translations for update notifications and system status.
## [1.0.6] - 2026-03-13
### Added
- **Extended Themes**: Selection expanded to 8 themes, including the complete Nord series (Nord, Snowstorm, Frost, Aurora).
- **Light Theme Support**: Enhanced readability for Hero and Fullscreen Player components when using light themes (Latte, Snowstorm).
### Fixed
- **Linux/Wayland Compatibility**: Fixed immediate crash on Wayland environments by forcing X11 backend for the AppImage.
- **Playback Stability**: Introduced seek debouncing to prevent audio stalls on Linux/GStreamer.
- **Windows Integration**: Improved drag-and-drop compatibility for systems using WebView2.
## [1.0.5] - 2026-03-12
### Added
+10 -17
View File
@@ -16,26 +16,26 @@ Psysonic is a beautiful desktop music player built completely from the ground up
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
![Psysonic Screenshot](public/screenshot.png)
## ✨ Features
- 🎨 **Gorgeous UI**: Deeply integrated Catppuccin themes (Mocha & Latte) with smooth glassmorphism effects and micro-animations.
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English and German (v1.0.0).
- 🌍 **Internationalization (i18n)**: Fully translated into English and German.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
- 💾 **Local Caching**: Fast loading times with customizable image caching thresholds.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux.
- 🔄 **Update Notifications**: Built-in update checker that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
## 🛡️ Security
## ● Known Limitations
With the **v1.0.0 Release**, Psysonic has been hardened for public use:
- **Secured Credentials**: Industry-standard security using Tauri's native encrypted store.
- **Hardened Sandbox**: Restricted filesystem permissions to only necessary directories.
- **Native Security**: Disabled global Tauri objects to significantly reduce XSS attack vectors.
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
## 📥 Installation
@@ -91,10 +91,3 @@ Contributions are completely welcome! Whether it is translating the app into a n
## 📄 License
Distributed under the MIT License. See `LICENSE` for more information.
---
<p align="center">
<i>Core Components developed with <b>Claude Code</b></i><br/>
<i>UI/UX developed with <b>Gemini 2.0 Flash</b> using <b>Antigravity</b></i>
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.0.5",
"version": "1.0.7",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.0.5"
version = "1.0.6"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.0.5"
version = "1.0.7"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.0.5",
"version": "1.0.7",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -29,7 +29,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/json', JSON.stringify({
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
+5 -5
View File
@@ -89,7 +89,7 @@ const FsPlayBtn = memo(function FsPlayBtn() {
const togglePlay = usePlayerStore(s => s.togglePlay);
return (
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={36} /> : <Play size={36} fill="currentColor" />}
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
);
});
@@ -163,21 +163,21 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
<Square size={20} fill="currentColor" />
<Square size={14} fill="currentColor" />
</button>
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={28} />
<SkipBack size={20} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<SkipForward size={28} />
<SkipForward size={20} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
onClick={toggleRepeat}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
</div>
</div>
+3 -3
View File
@@ -83,7 +83,7 @@ export default function PlayerBar() {
</button>
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={18} />
<SkipBack size={20} />
</button>
<button
@@ -92,11 +92,11 @@ export default function PlayerBar() {
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={18} />
<SkipForward size={20} />
</button>
<button
+35 -16
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
@@ -124,6 +124,7 @@ export default function QueuePanel() {
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
@@ -142,41 +143,57 @@ export default function QueuePanel() {
};
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/json', JSON.stringify({
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'queue_reorder',
index
}));
};
const onDragEnterItem = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
};
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
setDragOverIdx(index);
};
const onDragEnd = () => {
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
};
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
e.preventDefault();
// Handle internal queue reorder using state — more reliable than dataTransfer on Windows/WebView2
if (draggedIdx !== null) {
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
if (draggedIdx !== targetIdx) {
reorderQueue(draggedIdx, targetIdx);
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
return;
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
try {
const dataStr = e.dataTransfer.getData('application/json');
const dataStr = e.dataTransfer.getData('text/plain');
if (!dataStr) return;
const data = JSON.parse(dataStr);
if (data.type === 'queue_reorder') {
const fromIdx = data.index;
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
if (fromIdx !== undefined && fromIdx !== targetIdx) {
reorderQueue(fromIdx, targetIdx);
}
} else if (data.type === 'song') {
if (data.type === 'song') {
const track = data.track;
if (dropIndex !== undefined) {
// If dropped on a specific item, we might want to insert it there.
@@ -201,9 +218,10 @@ export default function QueuePanel() {
};
return (
<aside
className="queue-panel"
onDragOver={e => e.preventDefault()}
<aside
className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDrop={e => onDropQueue(e)}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
@@ -293,6 +311,7 @@ export default function QueuePanel() {
}}
draggable
onDragStart={(e) => onDragStart(e, idx)}
onDragEnter={(e) => onDragEnterItem(e)}
onDragOver={(e) => onDragOverItem(e, idx)}
onDragEnd={onDragEnd}
onDrop={(e) => {
+68 -7
View File
@@ -1,9 +1,9 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft, HelpCircle, Dices
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle
} from 'lucide-react';
const PsysonicLogo = () => (
@@ -21,14 +21,74 @@ const navItems = [
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
];
export default function Sidebar({
isCollapsed = false,
toggleCollapse
}: {
function isNewer(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
const [lMaj, lMin, lPat] = parse(latest);
const [cMaj, cMin, cPat] = parse(current);
if (lMaj !== cMaj) return lMaj > cMaj;
if (lMin !== cMin) return lMin > cMin;
return lPat > cPat;
}
function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; latestVersion: string }) {
const { t } = useTranslation();
if (isCollapsed) {
return (
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
<ArrowUpCircle size={20} />
</div>
);
}
return (
<div className="update-toast">
<div className="update-toast-header">
<ArrowUpCircle size={14} />
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
</div>
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
<a
className="update-toast-link"
href="https://github.com/Psychotoxical/psysonic/releases"
target="_blank"
rel="noopener noreferrer"
>
{t('sidebar.updateLink')}
</a>
</div>
);
}
export default function Sidebar({
isCollapsed = false,
toggleCollapse
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
const [latestVersion, setLatestVersion] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
try {
const { getVersion } = await import('@tauri-apps/api/app');
const current = await getVersion();
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, current)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
}
} catch {
// network unavailable or not running in Tauri — silently skip
}
}, 1500);
return () => { cancelled = true; clearTimeout(timer); };
}, []);
return (
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
@@ -61,10 +121,11 @@ export default function Sidebar({
))}
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink
to="/statistics"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
style={isCollapsed ? { marginTop: 'auto' } : undefined}
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
title={isCollapsed ? t('sidebar.statistics') : undefined}
>
<BarChart3 size={isCollapsed ? 22 : 18} />
+10 -2
View File
@@ -18,7 +18,10 @@ const enTranslation = {
settings: 'Settings',
help: 'Help',
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar'
collapse: 'Collapse Sidebar',
updateAvailable: 'Update available',
updateReady: '{{version}} is ready',
updateLink: 'Go to release →'
},
home: {
starred: 'Personal Favorites',
@@ -132,6 +135,7 @@ const enTranslation = {
remixTooltip: 'Load new random songs',
playAll: 'Play All',
trackTitle: 'Title',
trackArtist: 'Artist',
trackAlbum: 'Album',
trackFavorite: 'Favorite',
trackDuration: 'Duration',
@@ -371,7 +375,10 @@ const deTranslation = {
settings: 'Einstellungen',
help: 'Hilfe',
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden'
collapse: 'Sidebar ausblenden',
updateAvailable: 'Update verfügbar',
updateReady: '{{version}} ist bereit',
updateLink: 'Zum Release →'
},
home: {
starred: 'Persönliche Favoriten',
@@ -485,6 +492,7 @@ const deTranslation = {
remixTooltip: 'Neue Songs laden',
playAll: 'Alle abspielen',
trackTitle: 'Titel',
trackArtist: 'Künstler',
trackAlbum: 'Album',
trackFavorite: 'Favorit',
trackDuration: 'Dauer',
+1 -1
View File
@@ -424,7 +424,7 @@ export default function AlbumDetail() {
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
+1 -1
View File
@@ -75,7 +75,7 @@ export default function Favorites() {
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
+8 -4
View File
@@ -78,9 +78,10 @@ export default function RandomMix() {
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
@@ -90,7 +91,7 @@ export default function RandomMix() {
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
onDoubleClick={() => playTrack(song, songs)}
role="row"
draggable
@@ -101,7 +102,7 @@ export default function RandomMix() {
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
@@ -115,7 +116,10 @@ export default function RandomMix() {
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist" data-tooltip={song.artist}>{song.artist}</span>
</div>
<div className="track-info">
+9 -3
View File
@@ -179,8 +179,14 @@ export default function Settings() {
onChange={(e) => theme.setTheme(e.target.value as any)}
aria-label={t('settings.theme')}
>
<option value="mocha">Catppuccin Mocha (Dark)</option>
<option value="latte">Catppuccin Latte (Light)</option>
<option value="mocha">Catppuccin Mocha</option>
<option value="macchiato">Catppuccin Macchiato</option>
<option value="frappe">Catppuccin Frappé</option>
<option value="latte">Catppuccin Latte</option>
<option value="nord">Nord · Polar Night</option>
<option value="nord-snowstorm">Nord · Snowstorm</option>
<option value="nord-frost">Nord · Frost</option>
<option value="nord-aurora">Nord · Aurora</option>
</select>
</div>
</div>
@@ -368,7 +374,7 @@ export default function Settings() {
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.5
{t('settings.aboutVersion')} 1.0.7
</div>
</div>
</div>
+9 -1
View File
@@ -74,6 +74,7 @@ interface PlayerState {
}
let progressInterval: ReturnType<typeof setInterval> | null = null;
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
function clearProgress() {
if (progressInterval) {
@@ -243,8 +244,15 @@ export const usePlayerStore = create<PlayerState>()(
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration);
howl.seek(time);
// Update UI immediately for responsiveness
set({ progress, currentTime: time });
// Debounce the actual seek — GStreamer on Linux stalls if two seeks arrive
// before the first one completes (reproducible after the 2nd seek)
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
get().howl?.seek(time);
seekDebounce = null;
}, 100);
},
setVolume: (v) => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'latte';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora';
interface ThemeState {
theme: Theme;
+34 -34
View File
@@ -53,13 +53,13 @@
inset: 0;
background: linear-gradient(
to right,
rgba(30, 30, 46, 0.92) 0%,
rgba(30, 30, 46, 0.6) 50%,
rgba(30, 30, 46, 0.3) 100%
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.45) 50%,
rgba(0, 0, 0, 0.18) 100%
),
linear-gradient(
to top,
rgba(30, 30, 46, 0.95) 0%,
rgba(0, 0, 0, 0.88) 0%,
transparent 60%
);
}
@@ -94,7 +94,7 @@
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
color: var(--ctp-lavender);
margin-bottom: var(--space-2);
}
@@ -102,16 +102,16 @@
font-family: var(--font-display);
font-size: clamp(24px, 3vw, 36px);
font-weight: 700;
color: var(--text-primary);
color: #ffffff;
margin-bottom: var(--space-1);
line-height: 1.2;
text-shadow: 0 2px 12px rgba(0,0,0,0.5);
text-shadow: 0 2px 12px rgba(0,0,0,0.6);
max-width: 500px;
}
.hero-artist {
font-size: 16px;
color: var(--text-secondary);
color: rgba(255, 255, 255, 0.72);
margin-bottom: var(--space-3);
}
@@ -905,16 +905,16 @@
to { transform: translateY(0); opacity: 1; }
}
/* Blurred background — GPU layer, will not repaint with React re-renders */
/* Blurred background */
.fs-bg {
position: absolute;
inset: -10%;
background-size: cover;
background-position: center;
filter: blur(50px) brightness(0.28) saturate(1.6);
transform: scale(1.2) translateZ(0);
transform: scale(1.2);
z-index: 0;
will-change: auto;
will-change: opacity;
pointer-events: none;
transition: opacity 700ms ease;
}
@@ -963,7 +963,7 @@
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
align-items: flex-start;
justify-content: center;
gap: clamp(28px, 4vw, 72px);
padding: 72px clamp(40px, 6vw, 100px) 48px;
@@ -1019,7 +1019,7 @@
font-family: var(--font-display);
font-size: clamp(16px, 1.8vw, 26px);
font-weight: 800;
color: var(--text-primary);
color: #ffffff;
margin: 0 0 4px;
line-height: 1.2;
white-space: nowrap;
@@ -1029,12 +1029,12 @@
.fs-artist {
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 600;
color: var(--accent);
color: var(--ctp-lavender);
margin: 0;
}
.fs-album {
font-size: 13px;
color: var(--text-secondary);
color: rgba(255, 255, 255, 0.6);
margin: 3px 0 0;
white-space: nowrap;
overflow: hidden;
@@ -1049,7 +1049,7 @@
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: var(--radius-full);
color: var(--text-muted);
color: rgba(255, 255, 255, 0.45);
margin-top: 6px;
}
@@ -1098,14 +1098,14 @@
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
gap: 11px;
}
.fs-btn {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
width: 34px;
height: 34px;
border-radius: 50%;
color: var(--text-secondary);
cursor: pointer;
@@ -1114,29 +1114,29 @@
}
.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
.fs-btn.active { color: var(--accent); }
.fs-btn-sm { width: 36px; height: 36px; }
.fs-btn-sm { width: 25px; height: 25px; }
.fs-btn-play {
width: 68px;
height: 68px;
background: white;
color: #1e1e2e;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.5);
}
.fs-btn-play:hover {
background: var(--ctp-lavender);
color: #1e1e2e;
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.06);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
box-shadow: 0 8px 28px rgba(0,0,0,0.6);
}
/* Right column: upcoming tracks — same vertical center as left column */
/* Right column: upcoming tracks — top-aligned with cover */
.fs-right {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
width: clamp(240px, 26vw, 380px);
align-self: center;
align-self: flex-start;
height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px));
overflow: hidden;
}
@@ -1145,7 +1145,7 @@
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
color: rgba(255, 255, 255, 0.4);
margin: 0;
flex-shrink: 0;
}
@@ -1195,21 +1195,21 @@
.fs-upcoming-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-artist {
font-size: 12px;
color: var(--text-muted);
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-dur {
font-size: 12px;
color: var(--text-muted);
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
+78 -10
View File
@@ -164,6 +164,70 @@
color: var(--text-primary);
}
/* ─── Update Toast ─── */
@keyframes update-toast-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.update-toast {
margin: 0 var(--space-1) var(--space-2);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
background: var(--accent-dim);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
display: flex;
flex-direction: column;
gap: 3px;
animation: update-toast-in 0.35s ease both;
}
.update-toast-header {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--accent);
}
.update-toast-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.update-toast-version {
font-size: 12px;
color: var(--text-secondary);
padding-left: 22px;
}
.update-toast-link {
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-decoration: none;
padding-left: 22px;
opacity: 0.8;
transition: opacity var(--transition-fast);
}
.update-toast-link:hover {
opacity: 1;
text-decoration: underline;
}
.update-toast-icon {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2) 0;
color: var(--accent);
animation: update-toast-in 0.35s ease both;
cursor: default;
}
/* ─── Main Content ─── */
.main-content {
grid-area: main;
@@ -312,7 +376,7 @@
.player-buttons {
display: flex;
align-items: center;
gap: var(--space-2);
gap: var(--space-3);
}
.player-btn {
@@ -322,29 +386,31 @@
width: 36px;
height: 36px;
border-radius: 50%;
color: var(--text-secondary);
color: var(--text-muted);
transition: all var(--transition-fast);
}
.player-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.12);
}
.player-btn-primary {
width: 42px;
height: 42px;
background: var(--text-primary);
color: var(--bg-app);
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
border-radius: 50%;
box-shadow: var(--shadow-sm);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
flex-shrink: 0;
}
.player-btn-primary:hover {
background: var(--accent);
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.05);
box-shadow: var(--shadow-glow);
transform: scale(1.07);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
}
.player-progress {
@@ -528,6 +594,8 @@
transition: background var(--transition-fast);
color: var(--text-secondary);
}
/* Prevent child elements from stealing dragenter/dragleave events */
.queue-item > * { pointer-events: none; }
.queue-item:hover {
background: var(--bg-hover);
+315
View File
@@ -56,6 +56,106 @@
--danger: var(--ctp-red);
}
/* ─── Catppuccin Macchiato Variables ─── */
[data-theme='macchiato'] {
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%23b8c0e0%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");
--ctp-rosewater: #f4dbd6;
--ctp-flamingo: #f0c6c6;
--ctp-pink: #f5bde6;
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-maroon: #ee99a0;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-teal: #8bd5ca;
--ctp-sky: #91d7e3;
--ctp-sapphire: #7dc4e4;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext1: #b8c0e0;
--ctp-subtext0: #a5adcb;
--ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface2: #5b6078;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
--bg-app: var(--ctp-base);
--bg-sidebar: var(--ctp-mantle);
--bg-card: var(--ctp-surface0);
--bg-hover: var(--ctp-surface1);
--bg-player: var(--ctp-crust);
--bg-glass: rgba(36, 39, 58, 0.75);
--accent: var(--ctp-mauve);
--accent-dim: rgba(198, 160, 246, 0.15);
--accent-glow: rgba(198, 160, 246, 0.3);
--text-primary: var(--ctp-text);
--text-secondary:var(--ctp-subtext1);
--text-muted: var(--ctp-overlay1);
--border: var(--ctp-surface1);
--border-subtle: var(--ctp-surface0);
--positive: var(--ctp-green);
--warning: var(--ctp-yellow);
--danger: var(--ctp-red);
}
/* ─── Catppuccin Frappé Variables ─── */
[data-theme='frappe'] {
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%23b5bfe2%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");
--ctp-rosewater: #f2d5cf;
--ctp-flamingo: #eebebe;
--ctp-pink: #f4b8e4;
--ctp-mauve: #ca9ee6;
--ctp-red: #e78284;
--ctp-maroon: #ea999c;
--ctp-peach: #ef9f76;
--ctp-yellow: #e5c890;
--ctp-green: #a6d189;
--ctp-teal: #81c8be;
--ctp-sky: #99d1db;
--ctp-sapphire: #85c1dc;
--ctp-blue: #8caaee;
--ctp-lavender: #babbf1;
--ctp-text: #c6d0f5;
--ctp-subtext1: #b5bfe2;
--ctp-subtext0: #a5adce;
--ctp-overlay2: #949cbb;
--ctp-overlay1: #838ba7;
--ctp-overlay0: #737994;
--ctp-surface2: #626880;
--ctp-surface1: #51576d;
--ctp-surface0: #414559;
--ctp-base: #303446;
--ctp-mantle: #292c3c;
--ctp-crust: #232634;
--bg-app: var(--ctp-base);
--bg-sidebar: var(--ctp-mantle);
--bg-card: var(--ctp-surface0);
--bg-hover: var(--ctp-surface1);
--bg-player: var(--ctp-crust);
--bg-glass: rgba(48, 52, 70, 0.75);
--accent: var(--ctp-mauve);
--accent-dim: rgba(202, 158, 230, 0.15);
--accent-glow: rgba(202, 158, 230, 0.3);
--text-primary: var(--ctp-text);
--text-secondary:var(--ctp-subtext1);
--text-muted: var(--ctp-overlay1);
--border: var(--ctp-surface1);
--border-subtle: var(--ctp-surface0);
--positive: var(--ctp-green);
--warning: var(--ctp-yellow);
--danger: var(--ctp-red);
}
/* ─── Catppuccin Latte Variables (Light Theme) ─── */
[data-theme='latte'] {
color-scheme: light;
@@ -107,6 +207,221 @@
--danger: var(--ctp-red);
}
/* ─── Nord Polar Night (Dark) ─── */
[data-theme='nord'] {
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%23e5e9f0%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");
/* Polar Night */
--ctp-crust: #2e3440;
--ctp-mantle: #2e3440;
--ctp-base: #3b4252;
--ctp-surface0: #434c5e;
--ctp-surface1: #4c566a;
--ctp-surface2: #576070;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #8894a4;
--ctp-overlay2: #96a2b0;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Frost */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #81a1c1;
--ctp-mauve: #88c0d0;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-pink: #b48ead;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #3b4252;
--bg-sidebar: #2e3440;
--bg-card: #434c5e;
--bg-hover: #4c566a;
--bg-player: #2e3440;
--bg-glass: rgba(59, 66, 82, 0.75);
--accent: #88c0d0;
--accent-dim: rgba(136, 192, 208, 0.15);
--accent-glow: rgba(136, 192, 208, 0.3);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Nord Snow Storm (Light) ─── */
[data-theme='nord-snowstorm'] {
color-scheme: light;
--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%233b4252%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");
/* Snow Storm as backgrounds */
--ctp-crust: #d8dee9;
--ctp-mantle: #e5e9f0;
--ctp-base: #eceff4;
--ctp-surface0: #d8dee9;
--ctp-surface1: #c8d0de;
--ctp-surface2: #b8c2d0;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #636e7e;
--ctp-overlay2: #4c566a;
/* Polar Night as text */
--ctp-text: #2e3440;
--ctp-subtext1: #3b4252;
--ctp-subtext0: #434c5e;
/* Frost */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #81a1c1;
--ctp-mauve: #5e81ac;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #9a7200;
--ctp-green: #4e7c3f;
--ctp-pink: #7e5d8c;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #eceff4;
--bg-sidebar: #e5e9f0;
--bg-card: #d8dee9;
--bg-hover: #c8d0de;
--bg-player: #e5e9f0;
--bg-glass: rgba(236, 239, 244, 0.80);
--accent: #5e81ac;
--accent-dim: rgba(94, 129, 172, 0.15);
--accent-glow: rgba(94, 129, 172, 0.3);
--text-primary: #2e3440;
--text-secondary:#3b4252;
--text-muted: #636e7e;
--border: #c8d0de;
--border-subtle: #d8dee9;
--positive: #4e7c3f;
--warning: #9a7200;
--danger: #bf616a;
}
/* ─── Nord Frost (Deep Ocean Blue) ─── */
[data-theme='nord-frost'] {
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%23e5e9f0%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");
/* Deep ocean blue Frost-inspired dark backgrounds */
--ctp-crust: #1a2535;
--ctp-mantle: #1d2b3a;
--ctp-base: #253545;
--ctp-surface0: #2d3f52;
--ctp-surface1: #354a60;
--ctp-surface2: #3d546e;
--ctp-overlay0: #6888a4;
--ctp-overlay1: #7898b4;
--ctp-overlay2: #88a8c4;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Frost accent */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #8fbcbb;
--ctp-mauve: #88c0d0;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-pink: #b48ead;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #253545;
--bg-sidebar: #1d2b3a;
--bg-card: #2d3f52;
--bg-hover: #354a60;
--bg-player: #1a2535;
--bg-glass: rgba(37, 53, 69, 0.75);
--accent: #88c0d0;
--accent-dim: rgba(136, 192, 208, 0.15);
--accent-glow: rgba(136, 192, 208, 0.35);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #7898b4;
--border: #2d3f52;
--border-subtle: #253545;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Nord Aurora (Dark + Aurora Purple) ─── */
[data-theme='nord-aurora'] {
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%23e5e9f0%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");
/* Polar Night base */
--ctp-crust: #2e3440;
--ctp-mantle: #2e3440;
--ctp-base: #3b4252;
--ctp-surface0: #434c5e;
--ctp-surface1: #4c566a;
--ctp-surface2: #576070;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #8894a4;
--ctp-overlay2: #96a2b0;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Aurora accents — purple as mauve/lavender for button gradients */
--ctp-mauve: #b48ead;
--ctp-lavender: #a3be8c;
--ctp-pink: #b48ead;
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #3b4252;
--bg-sidebar: #2e3440;
--bg-card: #434c5e;
--bg-hover: #4c566a;
--bg-player: #2e3440;
--bg-glass: rgba(59, 66, 82, 0.75);
--accent: #b48ead;
--accent-dim: rgba(180, 142, 173, 0.15);
--accent-glow: rgba(180, 142, 173, 0.3);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Global Base Settings ─── */
:root {
/* Typography */