mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 492f069bf1 | |||
| f8c45efd2b | |||
| 7e0cffc892 | |||
| 04773e83f7 | |||
| 45a220989f | |||
| 8a5cca799b | |||
| ee49258c4a | |||
| df9334f844 |
@@ -6,7 +6,42 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `app-v${process.env.PACKAGE_VERSION}`,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body: 'See the assets to download this version and install.',
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
return data.id
|
||||
|
||||
build-macos-windows:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -17,20 +52,49 @@ jobs:
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-libav
|
||||
|
||||
- name: install linuxdeploy gstreamer plugin
|
||||
run: |
|
||||
wget -q "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
|
||||
-O /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -39,19 +103,32 @@ jobs:
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- name: build
|
||||
run: npm run tauri:build
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
|
||||
- name: patch AppImage AppRun
|
||||
run: |
|
||||
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\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
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool squashfs-root "$APPIMAGE"
|
||||
rm -rf squashfs-root appimagetool
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tagName: app-v__VERSION__
|
||||
releaseName: 'Psysonic v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: ${{ matrix.settings.args }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
@@ -5,6 +5,50 @@ 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.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
|
||||
- **Image Caching**: Integrated IndexedDB-based image caching for cover art and artist images, providing significantly faster loading times for frequently accessed items.
|
||||
- **Improved Artist Discovery**: Faster scrolling in the Artists list using color-coded initial-based avatars for quick visual identification.
|
||||
- **Random Albums**: New discovery page for exploring your library with random album selections.
|
||||
- **Help & Documentation**: Added a dedicated help page for better user onboarding.
|
||||
|
||||
### Changed
|
||||
- **Optimized UI**: Instant "Now Playing" status updates via local state filtering for a more responsive experience.
|
||||
- **Enhanced Data Flow**: General performance improvements in server communication and state management.
|
||||
|
||||
## [1.0.4] - 2026-03-12
|
||||
|
||||
### Added
|
||||
- **Album Downloads**: Support for downloading entire albums with real-time progress tracking.
|
||||
|
||||
### Fixed
|
||||
- **Linux GPU Compatibility**: Patched AppImage to disable DMABUF renderer, fixing EGL/GPU crashes on older hardware.
|
||||
- **CI/CD Reliability**: Optimized release workflow with split jobs for better stability across platforms.
|
||||
|
||||
## [1.0.3] - 2026-03-12
|
||||
|
||||
### Fixed
|
||||
- **CI/CD Build**: Resolved build conflicts on Ubuntu 22.04 by removing redundant dev packages (`libunwind-dev`, gstreamer dev).
|
||||
- **Linux AppImage**: Configured GStreamer bundling and verified runtime environment settings.
|
||||
|
||||
## [1.0.2] - 2026-03-11
|
||||
|
||||
### Fixed
|
||||
- **Linux AppImage**: Integrated GStreamer bundling fix in CI/CD workflow.
|
||||
- **CI/CD Reliability**: Set `APPIMAGE_EXTRACT_AND_RUN=1` to prevent FUSE-related issues.
|
||||
|
||||
## [1.0.1] - 2026-03-11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -91,3 +91,10 @@ 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -2732,7 +2732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.0.0"
|
||||
version = "1.0.6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.0.1"
|
||||
version = "1.0.6"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.6",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -44,6 +44,11 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import AlbumDetail from './pages/AlbumDetail';
|
||||
import LabelAlbums from './pages/LabelAlbums';
|
||||
import Statistics from './pages/Statistics';
|
||||
import Playlists from './pages/Playlists';
|
||||
import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
@@ -142,6 +144,7 @@ function AppShell() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/random-albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
@@ -152,6 +155,7 @@ function AppShell() {
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -303,6 +303,12 @@ export function buildStreamUrl(id: string): string {
|
||||
return `${baseUrl}/rest/stream.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
/** Stable cache key for cover art — does not include ephemeral auth params. */
|
||||
export function coverArtCacheKey(id: string, size = 256): string {
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
return `${server?.id ?? '_'}:cover:${id}:${size}`;
|
||||
}
|
||||
|
||||
export function buildCoverArtUrl(id: string, size = 256): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
@@ -28,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,
|
||||
@@ -37,7 +38,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
>
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<img src={coverUrl} alt={`${album.name} Cover`} loading="lazy" />
|
||||
<CachedImage src={coverUrl} cacheKey={coverArtCacheKey(album.coverArt!, 300)} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) {
|
||||
const resolvedSrc = useCachedUrl(src, cacheKey);
|
||||
return <img src={resolvedSrc} {...props} />;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
ChevronDown, Repeat, Repeat1, Square, Music
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -63,20 +64,18 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)), [seek]);
|
||||
|
||||
return (
|
||||
<div className="fs-bottom">
|
||||
<div className="fs-progress-wrap">
|
||||
<span className="fs-time">{formatTime(currentTime)}</span>
|
||||
<div className="fs-progress-bar">
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
|
||||
aria-label="progress"
|
||||
/>
|
||||
</div>
|
||||
<span className="fs-time">{formatTime(duration)}</span>
|
||||
<div className="fs-progress-wrap">
|
||||
<span className="fs-time">{formatTime(currentTime)}</span>
|
||||
<div className="fs-progress-bar">
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
|
||||
aria-label="progress"
|
||||
/>
|
||||
</div>
|
||||
<span className="fs-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -90,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>
|
||||
);
|
||||
});
|
||||
@@ -112,9 +111,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
@@ -126,26 +127,59 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
{/* Crossfading blurred background */}
|
||||
<FsBg url={coverUrl} />
|
||||
<FsBg url={resolvedCoverUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
|
||||
{/* Close button */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')}>
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')} data-tooltip-pos="bottom">
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Main layout: cover left, upcoming right */}
|
||||
{/* Two-column layout: left = cover + info + controls, right = playlist */}
|
||||
<div className="fs-layout">
|
||||
|
||||
{/* Left column: cover only */}
|
||||
{/* Left column */}
|
||||
<div className="fs-left">
|
||||
<div className="fs-cover-wrap">
|
||||
{coverUrl ? (
|
||||
<img src={coverUrl} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
|
||||
<CachedImage src={coverUrl} cacheKey={coverKey} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fs-track-info">
|
||||
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FsProgress duration={duration} />
|
||||
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<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={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: upcoming tracks */}
|
||||
@@ -160,7 +194,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
onClick={() => playTrack(track, queue)}
|
||||
>
|
||||
{track.coverArt ? (
|
||||
<img src={buildCoverArtUrl(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
|
||||
<CachedImage src={buildCoverArtUrl(track.coverArt, 80)} cacheKey={coverArtCacheKey(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
|
||||
) : (
|
||||
<div className="fs-upcoming-art fs-upcoming-placeholder"><Music size={14} /></div>
|
||||
)}
|
||||
@@ -175,46 +209,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: meta + progress + controls — centered across full width */}
|
||||
<div className="fs-footer">
|
||||
<div className="fs-footer-main">
|
||||
<div className="fs-track-info">
|
||||
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar — isolated sub-component */}
|
||||
<FsProgress duration={duration} />
|
||||
</div>
|
||||
|
||||
{/* Transport controls */}
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
|
||||
<Square size={20} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</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} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+102
-24
@@ -1,26 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, getAlbum } from '../api/subsonic';
|
||||
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Hero() {
|
||||
const { t } = useTranslation();
|
||||
const [album, setAlbum] = useState<SubsonicAlbum | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const INTERVAL_MS = 10000;
|
||||
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||
function HeroBg({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counter = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getRandomAlbums(1).then(albums => {
|
||||
if (!cancelled && albums[0]) setAlbum(albums[0]);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
if (!url) return;
|
||||
const id = counter.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
|
||||
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(layer => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="hero-bg"
|
||||
style={{
|
||||
backgroundImage: `url(${layer.url})`,
|
||||
opacity: layer.visible ? 1 : 0,
|
||||
filter: layer.visible ? 'blur(0px)' : 'blur(18px)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (len <= 1) return;
|
||||
timerRef.current = setInterval(() => {
|
||||
setActiveIdx(prev => (prev + 1) % len);
|
||||
}, INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
|
||||
useEffect(() => {
|
||||
startTimer(albums.length);
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [albums.length, startTimer]);
|
||||
|
||||
const goTo = useCallback((idx: number) => {
|
||||
setActiveIdx(idx);
|
||||
startTimer(albums.length);
|
||||
}, [albums.length, startTimer]);
|
||||
|
||||
const album = albums[activeIdx] ?? null;
|
||||
|
||||
// Resolve background URL via cache
|
||||
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
|
||||
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
|
||||
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
|
||||
|
||||
// Resolve cover thumbnail via cache
|
||||
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -30,17 +93,18 @@ export default function Hero() {
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{coverUrl && (
|
||||
<div
|
||||
className="hero-bg"
|
||||
style={{ backgroundImage: `url(${coverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<HeroBg url={resolvedBgUrl} />
|
||||
<div className="hero-overlay" aria-hidden="true" />
|
||||
<div className="hero-content animate-fade-in">
|
||||
{coverUrl && (
|
||||
<img className="hero-cover" src={coverUrl} alt={`${album.name} Cover`} />
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
{coverRawUrl && (
|
||||
<CachedImage
|
||||
className="hero-cover"
|
||||
src={coverRawUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={`${album.name} Cover`}
|
||||
/>
|
||||
)}
|
||||
<div className="hero-text">
|
||||
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
|
||||
@@ -73,7 +137,7 @@ export default function Hero() {
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (err) { }
|
||||
} catch (_) { }
|
||||
}}
|
||||
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
@@ -84,6 +148,20 @@ export default function Hero() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel dot indicators */}
|
||||
{albums.length > 1 && (
|
||||
<div className="hero-dots" onClick={e => e.stopPropagation()}>
|
||||
{albums.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`hero-dot${i === activeIdx ? ' hero-dot-active' : ''}`}
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={`Album ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { PlayCircle, User, Radio, RefreshCw } from 'lucide-react';
|
||||
import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NowPlayingDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -22,10 +26,16 @@ export default function NowPlayingDropdown() {
|
||||
}
|
||||
};
|
||||
|
||||
// Poll in background so the badge stays current without opening the dropdown
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
fetchNowPlaying();
|
||||
const id = setInterval(fetchNowPlaying, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Refresh immediately when dropdown is opened
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchNowPlaying();
|
||||
}, [isOpen]);
|
||||
|
||||
// Click outside to close
|
||||
@@ -39,6 +49,12 @@ export default function NowPlayingDropdown() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// For the current user, trust the local player state — the server keeps stale
|
||||
// "now playing" entries for minutes after playback stops.
|
||||
const visible = nowPlaying.filter(entry =>
|
||||
entry.username === ownUsername ? isPlaying : true
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="now-playing-dropdown" ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -48,9 +64,9 @@ export default function NowPlayingDropdown() {
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
|
||||
>
|
||||
<Radio size={18} className={nowPlaying.length > 0 ? 'animate-pulse' : ''} style={{ color: nowPlaying.length > 0 ? 'var(--accent)' : 'inherit' }} />
|
||||
<Radio size={18} className={visible.length > 0 ? 'animate-pulse' : ''} style={{ color: visible.length > 0 ? 'var(--accent)' : 'inherit' }} />
|
||||
<span>Live</span>
|
||||
{nowPlaying.length > 0 && (
|
||||
{visible.length > 0 && (
|
||||
<span style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--ctp-crust)',
|
||||
@@ -59,7 +75,7 @@ export default function NowPlayingDropdown() {
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
{nowPlaying.length}
|
||||
{visible.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -94,17 +110,17 @@ export default function NowPlayingDropdown() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && nowPlaying.length === 0 ? (
|
||||
{loading && visible.length === 0 ? (
|
||||
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
|
||||
{t('nowPlaying.loading')}
|
||||
</div>
|
||||
) : nowPlaying.length === 0 ? (
|
||||
) : visible.length === 0 ? (
|
||||
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
|
||||
{t('nowPlaying.nobody')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{nowPlaying.map((stream, idx) => (
|
||||
{visible.map((stream, idx) => (
|
||||
<div key={`${stream.id}-${idx}`} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px' }}>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
{stream.coverArt ? (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, List, Square, Repeat, Repeat1, Maximize2
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -18,6 +19,8 @@ export default function PlayerBar() {
|
||||
const { currentTrack, isPlaying, progress, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
seek(parseFloat(e.target.value));
|
||||
@@ -45,11 +48,11 @@ export default function PlayerBar() {
|
||||
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
|
||||
>
|
||||
{currentTrack?.coverArt ? (
|
||||
<img
|
||||
<CachedImage
|
||||
className="player-album-art"
|
||||
src={buildCoverArtUrl(currentTrack.coverArt, 128)}
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
alt={`${currentTrack.album} Cover`}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="player-album-art-placeholder">
|
||||
@@ -80,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
|
||||
@@ -89,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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
||||
PanelLeftClose, PanelLeft
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
@@ -13,7 +13,8 @@ const PsysonicLogo = () => (
|
||||
const navItems = [
|
||||
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
|
||||
{ icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' },
|
||||
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
||||
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
||||
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
|
||||
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
|
||||
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
|
||||
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
|
||||
@@ -69,6 +70,14 @@ export default function Sidebar({
|
||||
<BarChart3 size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/help"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t('sidebar.help') : undefined}
|
||||
>
|
||||
<HelpCircle size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.help')}</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
|
||||
+142
-2
@@ -8,6 +8,7 @@ const enTranslation = {
|
||||
mainstage: 'Mainstage',
|
||||
newReleases: 'New Releases',
|
||||
allAlbums: 'All Albums',
|
||||
randomAlbums: 'Random Albums',
|
||||
artists: 'Artists',
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Random Mix',
|
||||
@@ -15,6 +16,7 @@ const enTranslation = {
|
||||
system: 'System',
|
||||
statistics: 'Statistics',
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
expand: 'Expand Sidebar',
|
||||
collapse: 'Collapse Sidebar'
|
||||
},
|
||||
@@ -69,6 +71,8 @@ const enTranslation = {
|
||||
artistBio: 'Artist Bio',
|
||||
download: 'Download (ZIP)',
|
||||
downloading: 'Loading…',
|
||||
downloadHint: 'FLAC/WAV albums are zipped server-side first — large albums may take a moment before the download starts.',
|
||||
downloadHintShort: 'Server zips first — may take a moment depending on file size',
|
||||
favoriteAdd: 'Add to Favorites',
|
||||
favoriteRemove: 'Remove from Favorites',
|
||||
favorite: 'Favorite',
|
||||
@@ -78,10 +82,12 @@ const enTranslation = {
|
||||
goToArtist: 'Go to {{artist}}',
|
||||
moreLabelAlbums: 'More albums on {{label}}',
|
||||
trackTitle: 'Title',
|
||||
trackArtist: 'Artist',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favorite',
|
||||
trackRating: 'Rating',
|
||||
trackDuration: 'Duration',
|
||||
trackTotal: 'Total',
|
||||
notFound: 'Album not found.',
|
||||
bioModal: 'Artist Biography',
|
||||
bioClose: 'Close',
|
||||
@@ -116,12 +122,17 @@ const enTranslation = {
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
refresh: 'Refresh',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Random Mix',
|
||||
remix: 'Remix',
|
||||
remixTooltip: 'Load new random songs',
|
||||
playAll: 'Play All',
|
||||
trackTitle: 'Title',
|
||||
trackArtist: 'Artist',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favorite',
|
||||
trackDuration: 'Duration',
|
||||
@@ -236,7 +247,66 @@ const enTranslation = {
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
pickFolderTitle: 'Select Download Folder',
|
||||
logout: 'Logout'
|
||||
logout: 'Logout',
|
||||
aboutTitle: 'About Psysonic',
|
||||
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
|
||||
aboutFeatures: 'Multi-server support · Scrobbling · Fullscreen player · Album downloads · Image caching · Catppuccin themes',
|
||||
aboutLicense: 'License',
|
||||
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
|
||||
aboutRepo: 'Source Code on GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js'
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
s1: 'Getting Started',
|
||||
q1: 'Which servers are compatible?',
|
||||
a1: 'Psysonic works with any Subsonic-compatible server: Navidrome, Gonic, Subsonic, Airsonic, and others. Navidrome is the recommended choice.',
|
||||
q2: 'How do I connect to my server?',
|
||||
a2: 'Open Settings and click "Add Server". Enter the server URL (e.g. 192.168.1.100:4533), your username, and password. Psysonic tests the connection before saving — nothing is stored if the connection fails.',
|
||||
q3: 'Can I use multiple servers?',
|
||||
a3: 'Yes. You can add as many servers as you like in Settings and switch between them at any time. Only one server is active at a time.',
|
||||
s2: 'Playback',
|
||||
q4: 'How do I play music?',
|
||||
a4: 'Double-click any track to play it. On album and artist pages, use "Play All" to start the whole album. You can also drag tracks into the queue panel.',
|
||||
q5: 'What keyboard shortcuts are available?',
|
||||
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on macOS and Windows. On Linux, use the player bar buttons.',
|
||||
q6: 'What is the queue?',
|
||||
a6: 'The queue shows all upcoming tracks. Open it with the list icon in the player bar. You can reorder tracks by dragging and save the current queue as a playlist.',
|
||||
q7: 'How do I open the fullscreen player?',
|
||||
a7: 'Click the album art thumbnail in the player bar at the bottom, or the expand icon next to it. Press Escape to close it again.',
|
||||
q8: 'How does repeat work?',
|
||||
a8: 'Click the repeat button in the player bar to cycle through: Off → Repeat All → Repeat One.',
|
||||
s3: 'Library',
|
||||
q9: 'How do I download an album?',
|
||||
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server zips the album first — this may take a moment for large albums or lossless files (FLAC / WAV). A progress bar shows the download status.',
|
||||
q10: 'How do I star / favorite tracks and albums?',
|
||||
a10: 'Click the star icon on any track row or on the album header. Starred items appear in the Favorites section in the sidebar.',
|
||||
q11: 'What is the hero carousel on the home page?',
|
||||
a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.',
|
||||
s4: 'Settings',
|
||||
q12: 'How do I change the theme?',
|
||||
a12: 'Settings → Theme. Choose between Catppuccin Mocha (dark) and Catppuccin Latte (light).',
|
||||
q13: 'How do I change the language?',
|
||||
a13: 'Settings → Language. English and German are currently supported.',
|
||||
q14: 'What does "Minimize to Tray" do?',
|
||||
a14: 'When enabled, clicking the × button hides Psysonic to the system tray instead of closing it. Music keeps playing. Right-click the tray icon for controls or to quit.',
|
||||
q15: 'How do I set a download folder?',
|
||||
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
|
||||
s5: 'Scrobbling',
|
||||
q16: 'How does scrobbling work?',
|
||||
a16: 'Psysonic uses your Navidrome server\'s built-in Last.fm integration. First, connect your Last.fm account in Navidrome\'s web interface. Then enable scrobbling in Psysonic\'s Settings.',
|
||||
q17: 'When is a scrobble sent?',
|
||||
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.',
|
||||
s6: 'Troubleshooting',
|
||||
q18: 'Cover art and artist images load slowly.',
|
||||
a18: 'Images are fetched from your server\'s disk on first load and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page may take a moment. Subsequent visits will be instant.',
|
||||
q19: 'The connection test fails.',
|
||||
a19: 'Check the URL including the port (e.g. http://192.168.1.100:4533). Make sure no firewall blocks the connection. Try http:// instead of https:// on a local network. Also verify that your username and password are correct.',
|
||||
q20: 'No audio on Linux (AppImage).',
|
||||
a20: 'The AppImage bundles GStreamer. If audio still doesn\'t work, try installing system GStreamer packages: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.',
|
||||
q21: 'The app crashes or shows a black screen on old Linux hardware.',
|
||||
a21: 'This is usually caused by GPU / EGL driver issues in WebKitGTK. The official AppImage already patches the launcher to disable GPU compositing. If you build from source, launch with: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic',
|
||||
},
|
||||
queue: {
|
||||
title: 'Queue',
|
||||
@@ -292,6 +362,7 @@ const deTranslation = {
|
||||
mainstage: 'Mainstage',
|
||||
newReleases: 'Neueste',
|
||||
allAlbums: 'Alle Alben',
|
||||
randomAlbums: 'Zufallsalben',
|
||||
artists: 'Künstler',
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Zufallsmix',
|
||||
@@ -299,6 +370,7 @@ const deTranslation = {
|
||||
system: 'System',
|
||||
statistics: 'Statistiken',
|
||||
settings: 'Einstellungen',
|
||||
help: 'Hilfe',
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden'
|
||||
},
|
||||
@@ -353,6 +425,8 @@ const deTranslation = {
|
||||
artistBio: 'Künstler-Bio',
|
||||
download: 'Download (ZIP)',
|
||||
downloading: 'Lade…',
|
||||
downloadHint: 'FLAC/WAV-Alben werden serverseitig zuerst gezippt — bei großen Alben kann es einen Moment dauern, bevor der Download startet.',
|
||||
downloadHintShort: 'Server zippt zuerst — je nach Dateigröße kann es etwas dauern bevor der Download startet',
|
||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||
favoriteRemove: 'Aus Favoriten entfernen',
|
||||
favorite: 'Als Favorit',
|
||||
@@ -362,10 +436,12 @@ const deTranslation = {
|
||||
goToArtist: 'Zu {{artist}} wechseln',
|
||||
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Interpret',
|
||||
trackFormat: 'Format',
|
||||
trackFavorite: 'Favorit',
|
||||
trackRating: 'Bewertung',
|
||||
trackDuration: 'Dauer',
|
||||
trackTotal: 'Gesamt',
|
||||
notFound: 'Album nicht gefunden.',
|
||||
bioModal: 'Künstler-Biografie',
|
||||
bioClose: 'Schließen',
|
||||
@@ -400,12 +476,17 @@ const deTranslation = {
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
refresh: 'Neu laden',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Zufallsmix',
|
||||
remix: 'Neu mixen',
|
||||
remixTooltip: 'Neue Songs laden',
|
||||
playAll: 'Alle abspielen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Künstler',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favorit',
|
||||
trackDuration: 'Dauer',
|
||||
@@ -520,7 +601,66 @@ const deTranslation = {
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
pickFolderTitle: 'Download-Ordner auswählen',
|
||||
logout: 'Abmelden'
|
||||
logout: 'Abmelden',
|
||||
aboutTitle: 'Über Psysonic',
|
||||
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
|
||||
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
|
||||
aboutLicense: 'Lizenz',
|
||||
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weitergabbar.',
|
||||
aboutRepo: 'Quellcode auf GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js'
|
||||
},
|
||||
help: {
|
||||
title: 'Hilfe',
|
||||
s1: 'Erste Schritte',
|
||||
q1: 'Welche Server sind kompatibel?',
|
||||
a1: 'Psysonic funktioniert mit jedem Subsonic-kompatiblen Server: Navidrome, Gonic, Subsonic, Airsonic und anderen. Navidrome ist die empfohlene Wahl.',
|
||||
q2: 'Wie verbinde ich mich mit meinem Server?',
|
||||
a2: 'Öffne die Einstellungen und klicke auf "Server hinzufügen". Gib die Server-URL (z. B. 192.168.1.100:4533), Benutzername und Passwort ein. Psysonic testet die Verbindung vor dem Speichern — bei Fehler wird nichts gespeichert.',
|
||||
q3: 'Kann ich mehrere Server verwenden?',
|
||||
a3: 'Ja. Du kannst beliebig viele Server in den Einstellungen hinzufügen und jederzeit zwischen ihnen wechseln. Immer ist nur ein Server aktiv.',
|
||||
s2: 'Wiedergabe',
|
||||
q4: 'Wie spiele ich Musik ab?',
|
||||
a4: 'Doppelklick auf einen Track startet die Wiedergabe. Auf Album- und Künstlerseiten gibt es "Alle abspielen". Tracks lassen sich auch per Drag & Drop in die Warteschlange ziehen.',
|
||||
q5: 'Welche Tastenkürzel gibt es?',
|
||||
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren unter macOS und Windows. Unter Linux bitte die Buttons in der Playerleiste nutzen.',
|
||||
q6: 'Was ist die Warteschlange?',
|
||||
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Listen-Icon in der Playerleiste. Tracks können per Drag & Drop umsortiert und als Playlist gespeichert werden.',
|
||||
q7: 'Wie öffne ich den Vollbild-Player?',
|
||||
a7: 'Klick auf das Album-Cover unten in der Playerleiste oder auf das Expand-Icon daneben. Mit Escape wieder schließen.',
|
||||
q8: 'Wie funktioniert die Wiederholfunktion?',
|
||||
a8: 'Klick auf den Wiederhol-Button in der Playerleiste, um zwischen den Modi zu wechseln: Aus → Alles wiederholen → Einen wiederholen.',
|
||||
s3: 'Bibliothek',
|
||||
q9: 'Wie lade ich ein Album herunter?',
|
||||
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server zieht das Album zuerst zusammen — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann das einen Moment dauern. Ein Fortschrittsbalken zeigt den Status.',
|
||||
q10: 'Wie markiere ich Tracks und Alben als Favoriten?',
|
||||
a10: 'Das Stern-Icon auf einem Track oder im Album-Header anklicken. Markierte Einträge erscheinen im Bereich "Favoriten" in der Seitenleiste.',
|
||||
q11: 'Was ist das Karussell auf der Startseite?',
|
||||
a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.',
|
||||
s4: 'Einstellungen',
|
||||
q12: 'Wie ändere ich das Theme?',
|
||||
a12: 'Einstellungen → Theme. Wahl zwischen Catppuccin Mocha (dunkel) und Catppuccin Latte (hell).',
|
||||
q13: 'Wie ändere ich die Sprache?',
|
||||
a13: 'Einstellungen → Sprache. Aktuell verfügbar: Englisch und Deutsch.',
|
||||
q14: 'Was bewirkt "In Tray minimieren"?',
|
||||
a14: 'Wenn aktiviert, versteckt sich Psysonic beim Klick auf × im System-Tray statt sich zu schließen. Die Musik läuft weiter. Rechtsklick auf das Tray-Icon zeigt Steueroptionen und "Beenden".',
|
||||
q15: 'Wie lege ich einen Download-Ordner fest?',
|
||||
a15: 'Einstellungen → App-Verhalten → Download-Ordner. Beliebigen Ordner wählen — heruntergeladene Alben werden dort als ZIP gespeichert. Ohne eigenen Ordner landet alles im Standard-Downloads-Ordner.',
|
||||
s5: 'Scrobbling',
|
||||
q16: 'Wie funktioniert Scrobbling?',
|
||||
a16: 'Psysonic nutzt die eingebaute Last.fm-Integration von Navidrome. Zuerst das Last.fm-Konto im Navidrome-Webinterface verbinden, dann Scrobbling in den Psysonic-Einstellungen aktivieren.',
|
||||
q17: 'Wann wird ein Scrobble gesendet?',
|
||||
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.',
|
||||
s6: 'Problemlösung',
|
||||
q18: 'Cover und Künstlerbilder laden langsam.',
|
||||
a18: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern. Danach geht alles sofort.',
|
||||
q19: 'Der Verbindungstest schlägt fehl.',
|
||||
a19: 'URL inklusive Port prüfen (z. B. http://192.168.1.100:4533). Sicherstellen, dass keine Firewall die Verbindung blockiert. Im lokalen Netzwerk http:// statt https:// versuchen. Benutzername und Passwort kontrollieren.',
|
||||
q20: 'Kein Ton unter Linux (AppImage).',
|
||||
a20: 'Das AppImage bündelt GStreamer. Falls trotzdem kein Ton kommt, diese System-Pakete installieren: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.',
|
||||
q21: 'Die App stürzt ab oder zeigt einen schwarzen Bildschirm auf alter Linux-Hardware.',
|
||||
a21: 'Das liegt meist an GPU/EGL-Treiberproblemen in WebKitGTK. Das offizielle AppImage deaktiviert GPU-Compositing bereits automatisch. Beim Selbst-Kompilieren: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic',
|
||||
},
|
||||
queue: {
|
||||
title: 'Warteschlange',
|
||||
|
||||
+72
-19
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus } from 'lucide-react';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -9,6 +9,7 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
@@ -106,7 +107,7 @@ export default function AlbumDetail() {
|
||||
const [bio, setBio] = useState<string | null>(null);
|
||||
const [bioOpen, setBioOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -174,13 +175,33 @@ export default function AlbumDetail() {
|
||||
};
|
||||
|
||||
const handleDownload = async (albumName: string, albumId: string) => {
|
||||
setDownloading(true);
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
|
||||
if (total && response.body) {
|
||||
const reader = response.body.getReader();
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
setDownloadProgress(Math.round((received / total) * 100));
|
||||
}
|
||||
} else {
|
||||
const buffer = await response.arrayBuffer() as ArrayBuffer;
|
||||
chunks.push(new Uint8Array(buffer));
|
||||
setDownloadProgress(100);
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
@@ -197,8 +218,10 @@ export default function AlbumDetail() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
// keep bar visible at 100% for 3 seconds so user sees completion
|
||||
setTimeout(() => setDownloadProgress(null), 60000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,22 +254,28 @@ export default function AlbumDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
// Hooks must be called unconditionally — derive from nullable album state
|
||||
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
|
||||
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const coverUrl = info.coverArt ? buildCoverArtUrl(info.coverArt, 400) : '';
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="album-detail animate-fade-in">
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{coverUrl && (
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${coverUrl})` }}
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
@@ -258,7 +287,7 @@ export default function AlbumDetail() {
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<img className="album-detail-cover" src={coverUrl} alt={`${info.name} Cover`} />
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
@@ -320,9 +349,23 @@ export default function AlbumDetail() {
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)} disabled={downloading}>
|
||||
<Download size={16} /> {downloading ? t('albumDetail.downloading') : t('albumDetail.download')}
|
||||
</button>
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
<span className="download-hint">
|
||||
<Info size={12} />
|
||||
{t('albumDetail.downloadHintShort')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,9 +373,10 @@ export default function AlbumDetail() {
|
||||
</div>
|
||||
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header">
|
||||
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
|
||||
<div>{t('albumDetail.trackRating')}</div>
|
||||
@@ -360,7 +404,7 @@ export default function AlbumDetail() {
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
|
||||
onDoubleClick={() => handlePlaySong(song)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -380,16 +424,18 @@ 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>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
{song.artist !== info.artist && (
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
)}
|
||||
</div>
|
||||
{hasVariousArtists && (
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec" style={{ marginTop: 0 }}>
|
||||
@@ -420,10 +466,17 @@ export default function AlbumDetail() {
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* Total row */}
|
||||
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
|
||||
<div style={{ borderTop: '1px solid var(--border-subtle)', marginBottom: '2rem' }} />
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist' | 'newest' | 'random';
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
export default function Albums() {
|
||||
const { t } = useTranslation();
|
||||
@@ -55,8 +55,6 @@ export default function Albums() {
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
{ value: 'newest', label: t('albums.sortNewest') },
|
||||
{ value: 'random', label: t('albums.sortRandom') },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, star, unstar } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -158,11 +159,12 @@ export default function ArtistDetail() {
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar">
|
||||
{coverId ? (
|
||||
<img
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={e => { e.currentTarget.style.display = 'none'; }}
|
||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
@@ -265,11 +267,12 @@ export default function ArtistDetail() {
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{song.coverArt && (
|
||||
<img
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 64)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 64)}
|
||||
alt={song.album}
|
||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
|
||||
+31
-34
@@ -1,13 +1,36 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { Users, LayoutGrid, List } from 'lucide-react';
|
||||
import { getArtists, SubsonicArtist } from '../api/subsonic';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ALL_SENTINEL = 'ALL';
|
||||
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||
|
||||
// Catppuccin accent colors — one is picked deterministically from the artist name
|
||||
const CTP_COLORS = [
|
||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
||||
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
|
||||
'var(--ctp-blue)', 'var(--ctp-lavender)',
|
||||
];
|
||||
|
||||
function nameColor(name: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return CTP_COLORS[h % CTP_COLORS.length];
|
||||
}
|
||||
|
||||
function nameInitial(name: string): string {
|
||||
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
|
||||
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
|
||||
if (letter) return letter.toUpperCase();
|
||||
// Fallback: first alphanumeric (e.g. "1349")
|
||||
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
|
||||
return alnum?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
export default function Artists() {
|
||||
const { t } = useTranslation();
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
@@ -124,7 +147,7 @@ export default function Artists() {
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<div className="album-grid-wrap">
|
||||
{visible.map(artist => {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<div
|
||||
key={artist.id}
|
||||
@@ -135,21 +158,8 @@ export default function Artists() {
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Users size={32} />
|
||||
)}
|
||||
<Users size={32} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
@@ -170,7 +180,7 @@ export default function Artists() {
|
||||
<h3 className="letter-heading">{letter}</h3>
|
||||
<div className="artist-list">
|
||||
{groups[letter].map(artist => {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<button
|
||||
key={artist.id}
|
||||
@@ -182,21 +192,8 @@ export default function Artists() {
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<div className="artist-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 100)}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Users size={18} />
|
||||
)}
|
||||
<Users size={18} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FaqItem { q: string; a: string; }
|
||||
interface FaqSection { icon: React.ReactNode; title: string; items: FaqItem[]; }
|
||||
|
||||
function AccordionItem({ q, a, open, onToggle }: FaqItem & { open: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className={`help-item${open ? ' help-item-open' : ''}`}>
|
||||
<button className="help-question" onClick={onToggle} aria-expanded={open}>
|
||||
<span>{q}</span>
|
||||
<ChevronDown size={16} className="help-chevron" />
|
||||
</button>
|
||||
{open && <div className="help-answer">{a}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Help() {
|
||||
const { t } = useTranslation();
|
||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||
|
||||
const toggle = (key: string) => setOpenKey(prev => prev === key ? null : key);
|
||||
|
||||
const sections: FaqSection[] = [
|
||||
{
|
||||
icon: <Rocket size={18} />,
|
||||
title: t('help.s1'),
|
||||
items: [
|
||||
{ q: t('help.q1'), a: t('help.a1') },
|
||||
{ q: t('help.q2'), a: t('help.a2') },
|
||||
{ q: t('help.q3'), a: t('help.a3') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Play size={18} />,
|
||||
title: t('help.s2'),
|
||||
items: [
|
||||
{ q: t('help.q4'), a: t('help.a4') },
|
||||
{ q: t('help.q5'), a: t('help.a5') },
|
||||
{ q: t('help.q6'), a: t('help.a6') },
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <LibraryBig size={18} />,
|
||||
title: t('help.s3'),
|
||||
items: [
|
||||
{ q: t('help.q9'), a: t('help.a9') },
|
||||
{ q: t('help.q10'), a: t('help.a10') },
|
||||
{ q: t('help.q11'), a: t('help.a11') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Settings2 size={18} />,
|
||||
title: t('help.s4'),
|
||||
items: [
|
||||
{ q: t('help.q12'), a: t('help.a12') },
|
||||
{ q: t('help.q13'), a: t('help.a13') },
|
||||
{ q: t('help.q14'), a: t('help.a14') },
|
||||
{ q: t('help.q15'), a: t('help.a15') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Radio size={18} />,
|
||||
title: t('help.s5'),
|
||||
items: [
|
||||
{ q: t('help.q16'), a: t('help.a16') },
|
||||
{ q: t('help.q17'), a: t('help.a17') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Wrench size={18} />,
|
||||
title: t('help.s6'),
|
||||
items: [
|
||||
{ q: t('help.q18'), a: t('help.a18') },
|
||||
{ q: t('help.q19'), a: t('help.a19') },
|
||||
{ q: t('help.q20'), a: t('help.a20') },
|
||||
{ q: t('help.q21'), a: t('help.a21') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{sections.map((section, si) => (
|
||||
<section key={si} className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
{section.icon}
|
||||
<h2>{section.title}</h2>
|
||||
</div>
|
||||
<div className="help-list">
|
||||
{section.items.map((item, ii) => {
|
||||
const key = `${si}-${ii}`;
|
||||
return <AccordionItem key={key} q={item.q} a={item.a} open={openKey === key} onToggle={() => toggle(key)} />;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const INTERVAL_MS = 30000;
|
||||
const ALBUM_COUNT = 30;
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [renderKey, setRenderKey] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList('random', ALBUM_COUNT);
|
||||
setAlbums(data);
|
||||
setRenderKey(k => k + 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCycle = useCallback(() => {
|
||||
// Clear existing timers
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (progressRef.current) clearInterval(progressRef.current);
|
||||
|
||||
// Reset progress bar
|
||||
setProgress(0);
|
||||
const startTime = Date.now();
|
||||
progressRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
setProgress(Math.min((elapsed / INTERVAL_MS) * 100, 100));
|
||||
}, 100);
|
||||
|
||||
// Auto-refresh
|
||||
timerRef.current = setInterval(() => {
|
||||
load().then(() => startCycle());
|
||||
}, INTERVAL_MS);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
load().then(() => startCycle());
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (progressRef.current) clearInterval(progressRef.current);
|
||||
};
|
||||
}, [load, startCycle]);
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
load().then(() => startCycle());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('randomAlbums.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Countdown progress bar */}
|
||||
<div className="random-albums-progress">
|
||||
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap animate-fade-in" key={renderKey}>
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
+60
-3
@@ -1,8 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
@@ -178,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>
|
||||
@@ -352,6 +359,56 @@ export default function Settings() {
|
||||
<LogOut size={16} /> {t('settings.logout')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* About */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Info size={18} />
|
||||
<h2>{t('settings.aboutTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card settings-about">
|
||||
<div className="settings-about-header">
|
||||
<img src="/logo.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
Psysonic
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.aboutVersion')} 1.0.6
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
|
||||
{t('settings.aboutDesc')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
|
||||
{t('settings.aboutFeatures')}
|
||||
</p>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
|
||||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('settings.aboutRepo')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
+278
-214
@@ -21,23 +21,45 @@
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: transform 8s ease;
|
||||
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.hero:hover .hero-bg { transform: scale(1); }
|
||||
|
||||
.hero-dots {
|
||||
position: absolute;
|
||||
bottom: var(--space-4);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
.hero-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.hero-dot:hover:not(.hero-dot-active) { background: rgba(255, 255, 255, 0.6); }
|
||||
.hero-dot-active { width: 24px; background: rgba(255, 255, 255, 0.9); }
|
||||
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
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%
|
||||
);
|
||||
}
|
||||
@@ -72,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);
|
||||
}
|
||||
|
||||
@@ -80,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);
|
||||
}
|
||||
|
||||
@@ -189,6 +211,19 @@
|
||||
color: var(--text-muted);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.artist-card-avatar-initial {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.artist-card-avatar-initial span {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-display);
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.artist-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
@@ -269,6 +304,20 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.random-albums-progress {
|
||||
height: 2px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.random-albums-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||
}
|
||||
@@ -428,12 +477,12 @@
|
||||
.album-detail-hero {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
padding: var(--space-4) 0 var(--space-6);
|
||||
}
|
||||
.album-detail-cover {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
width: clamp(120px, 15vw, 200px);
|
||||
height: clamp(120px, 15vw, 200px);
|
||||
border-radius: var(--radius-lg);
|
||||
object-fit: cover;
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -447,8 +496,9 @@
|
||||
font-size: 48px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.album-detail-title { font-family: var(--font-display); font-size: 32px; font-weight: 800; color: var(--text-primary); line-height: 1.1; margin: var(--space-2) 0 var(--space-1); }
|
||||
.album-detail-artist { font-size: 16px; color: var(--accent); font-weight: 600; }
|
||||
.album-detail-meta { min-width: 0; flex: 1; }
|
||||
.album-detail-title { font-family: var(--font-display); font-size: clamp(20px, 3vw, 32px); font-weight: 800; color: var(--text-primary); line-height: 1.1; margin: var(--space-2) 0 var(--space-1); overflow-wrap: break-word; }
|
||||
.album-detail-artist { font-size: clamp(13px, 1.5vw, 16px); color: var(--accent); font-weight: 600; }
|
||||
.album-detail-artist-link {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -464,14 +514,60 @@
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.album-detail-info { display: flex; gap: var(--space-3); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
|
||||
.album-detail-actions { display: flex; gap: var(--space-3); }
|
||||
.album-detail-info { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
|
||||
.album-detail-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; row-gap: var(--space-2); }
|
||||
|
||||
.download-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(203, 166, 247, 0.08);
|
||||
border: 1px solid rgba(203, 166, 247, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 3px 10px 3px 8px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
min-width: 180px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.download-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.download-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--ctp-mauve), var(--ctp-blue));
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.download-progress-pct {
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist { padding: 0 var(--space-6) var(--space-6); }
|
||||
.tracklist-header {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(120px, 3fr) minmax(100px, 2fr) 70px 80px 60px;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 11px;
|
||||
@@ -482,18 +578,53 @@
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.tracklist-header.tracklist-va {
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(120px, 3fr) minmax(100px, 2fr) 70px 80px 60px;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.track-row.track-row-va {
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
|
||||
}
|
||||
|
||||
.tracklist-total {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.tracklist-total.tracklist-va {
|
||||
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
|
||||
}
|
||||
.tracklist-total-label {
|
||||
grid-column: 1 / -2;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
.tracklist-total-value {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.track-row:hover { background: var(--bg-hover); }
|
||||
.track-row > * { padding-top: 6px; padding-bottom: 6px; }
|
||||
|
||||
/* CD / Disc separator */
|
||||
.disc-header {
|
||||
@@ -515,7 +646,8 @@
|
||||
|
||||
.track-num { font-size: 13px; color: var(--text-muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.track-info { min-width: 0; }
|
||||
.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); overflow-wrap: break-word; word-break: break-word; }
|
||||
.track-artist-cell { min-width: 0; display: flex; align-items: flex-start; }
|
||||
.track-artist { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-codec {
|
||||
display: block;
|
||||
@@ -632,6 +764,8 @@
|
||||
.artist-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); transition: background var(--transition-fast); width: 100%; text-align: left; }
|
||||
.artist-row:hover { background: var(--bg-hover); }
|
||||
.artist-avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent); flex-shrink: 0; }
|
||||
.artist-avatar-initial { background: var(--bg-card); border: 2px solid; }
|
||||
.artist-avatar-initial span { font-size: 1rem; font-weight: 700; font-family: var(--font-display); line-height: 1; user-select: none; }
|
||||
.artist-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||
.artist-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
@@ -647,7 +781,20 @@
|
||||
.settings-section-header { display: flex; align-items: center; gap: var(--space-2); color: var(--accent); margin-bottom: var(--space-3); }
|
||||
.settings-section-header h2 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||
.settings-card { background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); }
|
||||
|
||||
/* ─ Help Page ─ */
|
||||
.help-list { display: flex; flex-direction: column; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.help-item { border-bottom: 1px solid var(--border-subtle); }
|
||||
.help-item:last-child { border-bottom: none; }
|
||||
.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); transition: background var(--transition-fast); }
|
||||
.help-question:hover { background: var(--bg-hover); }
|
||||
.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); }
|
||||
.help-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform 0.2s ease; }
|
||||
.help-item-open .help-chevron { transform: rotate(180deg); color: var(--accent); }
|
||||
.help-answer { padding: var(--space-3) var(--space-5) var(--space-5); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-hover); border-top: 1px solid var(--border-subtle); }
|
||||
.settings-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
|
||||
.settings-about { display: flex; flex-direction: column; }
|
||||
.settings-about-header { display: flex; align-items: center; gap: var(--space-4); }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; }
|
||||
@@ -758,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;
|
||||
}
|
||||
@@ -809,32 +956,35 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Main layout: cover left, upcoming right ── */
|
||||
/* ── Main layout: two columns, left = cover+controls, right = playlist ── */
|
||||
.fs-layout {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: clamp(24px, 4vw, 60px);
|
||||
padding: clamp(52px, 8vh, 90px) clamp(32px, 5vw, 80px) 12px;
|
||||
gap: clamp(28px, 4vw, 72px);
|
||||
padding: 72px clamp(40px, 6vw, 100px) 48px;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left column */
|
||||
/* Left column: cover + track info + progress + controls */
|
||||
.fs-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
/* Width = cover size (cover fills 100% of this column) */
|
||||
width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px));
|
||||
}
|
||||
|
||||
/* Cover: explicit size, same formula as .fs-right */
|
||||
/* Cover fills the column width */
|
||||
.fs-cover-wrap {
|
||||
position: relative;
|
||||
width: clamp(240px, min(38vw, 50vh), 500px);
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
@@ -860,115 +1010,6 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Right column: same explicit width+height as cover */
|
||||
.fs-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
width: clamp(240px, min(38vw, 50vh), 500px);
|
||||
height: clamp(240px, min(38vw, 50vh), 500px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fs-upcoming-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.fs-upcoming-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-item:hover {
|
||||
background: rgba(255,255,255,0.07);
|
||||
}
|
||||
.fs-upcoming-art {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-placeholder {
|
||||
background: var(--ctp-surface1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.fs-upcoming-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.fs-upcoming-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-upcoming-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-upcoming-dur {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Footer: track info + progress + controls (centered, full width) ── */
|
||||
.fs-footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 0 clamp(40px, 8vw, 120px) 48px;
|
||||
}
|
||||
|
||||
.fs-footer-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
margin-top: clamp(20px, 4vh, 80px);
|
||||
margin-bottom: auto; /* Pushes controls down to bottom */
|
||||
}
|
||||
|
||||
/* Track metadata */
|
||||
.fs-track-info {
|
||||
text-align: center;
|
||||
@@ -976,9 +1017,9 @@
|
||||
}
|
||||
.fs-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(18px, 2.2vw, 28px);
|
||||
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;
|
||||
@@ -986,15 +1027,18 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-artist {
|
||||
font-size: clamp(13px, 1.2vw, 16px);
|
||||
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;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-codec {
|
||||
display: inline-block;
|
||||
@@ -1005,21 +1049,11 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* Progress + volume container */
|
||||
.fs-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
/* Progress bar */
|
||||
.fs-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1059,21 +1093,19 @@
|
||||
}
|
||||
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); }
|
||||
|
||||
|
||||
/* Controls: flat centered flex row */
|
||||
/* Transport controls */
|
||||
.fs-controls {
|
||||
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;
|
||||
@@ -1082,72 +1114,104 @@
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
@keyframes fsIn {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
/* 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: flex-start;
|
||||
height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Blurred cover background */
|
||||
.fs-bg {
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(40px) brightness(0.35) saturate(1.4);
|
||||
transform: scale(1.15);
|
||||
z-index: 0;
|
||||
.fs-upcoming-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fs-bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
rgba(17, 17, 27, 0.55) 0%,
|
||||
rgba(17, 17, 27, 0.85) 60%,
|
||||
rgba(17, 17, 27, 0.97) 100%
|
||||
);
|
||||
z-index: 0;
|
||||
.fs-upcoming-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.fs-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 24px;
|
||||
z-index: 10;
|
||||
.fs-upcoming-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-item:hover { background: rgba(255,255,255,0.07); }
|
||||
.fs-upcoming-art {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-placeholder {
|
||||
background: var(--ctp-surface1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.fs-close:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
.fs-upcoming-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.fs-upcoming-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-upcoming-artist {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-upcoming-dur {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
|
||||
+14
-10
@@ -312,7 +312,7 @@
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.player-btn {
|
||||
@@ -322,29 +322,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 +530,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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const DB_NAME = 'psysonic-img-cache';
|
||||
const STORE_NAME = 'images';
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session)
|
||||
const objectUrlCache = new Map<string, string>();
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (db) return Promise.resolve(db);
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = e => {
|
||||
const database = (e.target as IDBOpenDBRequest).result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = e => {
|
||||
db = (e.target as IDBOpenDBRequest).result;
|
||||
resolve(db!);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function getBlob(key: string): Promise<Blob | null> {
|
||||
try {
|
||||
const database = await openDB();
|
||||
return new Promise(resolve => {
|
||||
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(key);
|
||||
req.onsuccess = () => {
|
||||
const entry = req.result;
|
||||
resolve(entry && Date.now() - entry.timestamp < MAX_AGE_MS ? entry.blob : null);
|
||||
};
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function putBlob(key: string, blob: Blob): Promise<void> {
|
||||
try {
|
||||
const database = await openDB();
|
||||
await new Promise<void>(resolve => {
|
||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put({ key, blob, timestamp: Date.now() });
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
} catch {
|
||||
// Ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cached object URL for an image.
|
||||
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
||||
* @param cacheKey A stable key that identifies the image across sessions.
|
||||
*/
|
||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<string> {
|
||||
if (!fetchUrl) return '';
|
||||
|
||||
// 1. In-memory hit (same session)
|
||||
const existing = objectUrlCache.get(cacheKey);
|
||||
if (existing) return existing;
|
||||
|
||||
// 2. IndexedDB hit (persisted from previous session)
|
||||
const blob = await getBlob(cacheKey);
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
return objUrl;
|
||||
}
|
||||
|
||||
// 3. Network fetch → store in IDB → return object URL
|
||||
try {
|
||||
const resp = await fetch(fetchUrl);
|
||||
if (!resp.ok) return fetchUrl;
|
||||
const newBlob = await resp.blob();
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget
|
||||
const objUrl = URL.createObjectURL(newBlob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
return objUrl;
|
||||
} catch {
|
||||
return fetchUrl; // fallback: direct URL
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user