mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: v1.10.0 — new streaming themes, favourite toggle fix, home page improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,29 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.10.0] - 2026-03-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Three New Themes (Streaming Series)
|
||||||
|
- **Spotless**: Flat dark theme inspired by modern music streaming. Pitch-black sidebar (`#000000`), dark-grey app background (`#121212`), Spotify-green accent (`#1ED760`). White play button, green hover on primary actions.
|
||||||
|
- **DZR**: Flat light theme inspired by Deezer's modern redesign. White base, light-grey sidebar (`#F5F5F7`), purple accent (`#A238FF`). Crisp typography, large rounded radii.
|
||||||
|
- **Cupertino Beats**: Apple Music-inspired dark theme. Near-black base (`#1c1c1e`), frosted-glass sidebar and player bar with heavy `backdrop-filter`, red accent (`#fa243c`). Active nav links styled with `accent-dim` background.
|
||||||
|
- All three themes added to the **Psysonic Themes — Mediaplayer** group in the theme picker.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Favourite/Unfavourite toggle**: Right-clicking a song, album, or artist that is already starred now shows "Remove from Favourites" and calls `unstar()` correctly. Previously always showed "Add to Favourites" regardless of starred state.
|
||||||
|
- `Track` interface gained `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
|
||||||
|
- `starredOverrides: Record<string, boolean>` added to `playerStore` — updated immediately on star/unstar so the context menu and tracklist star icons reflect changes without a page reload.
|
||||||
|
- **Home page — Artist Discovery**: Replaced card grid (which loaded artist images and caused performance issues) with lightweight pill-buttons — same `artist-ext-link` style as the "Similar Artists" section on artist pages. No image loading, instant render.
|
||||||
|
- **Now Playing page**: Queue sidebar is no longer automatically hidden when entering the Now Playing page. It now behaves like all other pages and respects the user's current queue visibility setting.
|
||||||
|
- **Random Mix filter panel**: Background now correctly uses `--bg-card` instead of the undefined `--bg-elevated` token, which caused the panel to render transparent in most themes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Home page layout**: Section order is now: Recently Added → Discover → Artist Discovery → Starred → Most Played.
|
||||||
|
|
||||||
## [1.9.0] - 2026-03-21
|
## [1.9.0] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
|
|||||||
| `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` |
|
| `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` |
|
||||||
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` |
|
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` |
|
||||||
| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` |
|
| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` |
|
||||||
|
| `spotless` | Streaming Series | flat dark, Spotify-inspired | Green `#1ED760` |
|
||||||
|
| `dzr0` | Streaming Series | flat light, Deezer-inspired | Purple `#A238FF` |
|
||||||
|
| `cupertino-beats` | Streaming Series | Apple Music dark, glassmorphism | Red `#fa243c` |
|
||||||
|
|
||||||
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
|
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
|
||||||
|
|
||||||
@@ -207,7 +210,7 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
|||||||
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
|
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
|
||||||
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
|
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
|
||||||
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
|
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
|
||||||
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
|
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
|
||||||
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
|
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
|
||||||
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
|
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
|
||||||
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
|
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
|
||||||
@@ -222,4 +225,6 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
|||||||
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
|
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
|
||||||
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
|
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
|
||||||
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
|
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
|
||||||
- **Version**: 1.9.0
|
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
|
||||||
|
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
|
||||||
|
- **Version**: 1.10.0
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||||
pkgname=psysonic
|
pkgname=psysonic
|
||||||
pkgver=1.9.0
|
pkgver=1.10.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.9.0"
|
version = "1.10.0"
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Psysonic",
|
"productName": "Psysonic",
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"identifier": "dev.psysonic.player",
|
"identifier": "dev.psysonic.player",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { version } from '../../package.json';
|
||||||
|
|
||||||
// ─── Secure random salt ────────────────────────────────────────
|
// ─── Secure random salt ────────────────────────────────────────
|
||||||
function secureRandomSalt(): string {
|
function secureRandomSalt(): string {
|
||||||
@@ -13,7 +14,7 @@ function secureRandomSalt(): string {
|
|||||||
function getAuthParams(username: string, password: string) {
|
function getAuthParams(username: string, password: string) {
|
||||||
const salt = secureRandomSalt();
|
const salt = secureRandomSalt();
|
||||||
const token = md5(password + salt);
|
const token = md5(password + salt);
|
||||||
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
|
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export default function AlbumTrackList({
|
|||||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
|
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
|
||||||
coverArt: song.coverArt, track: song.track, year: song.year,
|
coverArt: song.coverArt, track: song.track, year: song.year,
|
||||||
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||||
|
starred: song.starred,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,45 +3,25 @@ import { SubsonicArtist } from '../api/subsonic';
|
|||||||
import ArtistCardLocal from './ArtistCardLocal';
|
import ArtistCardLocal from './ArtistCardLocal';
|
||||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
artists: SubsonicArtist[];
|
artists: SubsonicArtist[];
|
||||||
moreLink?: string;
|
moreLink?: string;
|
||||||
moreText?: string;
|
moreText?: string;
|
||||||
onLoadMore?: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
|
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showLeft, setShowLeft] = useState(false);
|
const [showLeft, setShowLeft] = useState(false);
|
||||||
const [showRight, setShowRight] = useState(true);
|
const [showRight, setShowRight] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||||
setShowLeft(scrollLeft > 0);
|
setShowLeft(scrollLeft > 0);
|
||||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||||
|
|
||||||
// Auto-load trigger
|
|
||||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
|
||||||
triggerLoadMore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerLoadMore = async () => {
|
|
||||||
if (!onLoadMore || loadingRef.current) return;
|
|
||||||
loadingRef.current = true;
|
|
||||||
setLoadingMore(true);
|
|
||||||
await onLoadMore();
|
|
||||||
setLoadingMore(false);
|
|
||||||
loadingRef.current = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,18 +43,10 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
|
|||||||
<div className="album-row-header">
|
<div className="album-row-header">
|
||||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||||
<div className="album-row-nav">
|
<div className="album-row-nav">
|
||||||
<button
|
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
|
||||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
|
||||||
onClick={() => scroll('left')}
|
|
||||||
disabled={!showLeft}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={20} />
|
<ChevronLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
|
||||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
|
||||||
onClick={() => scroll('right')}
|
|
||||||
disabled={!showRight}
|
|
||||||
>
|
|
||||||
<ChevronRight size={20} />
|
<ChevronRight size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,15 +55,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
|
|||||||
<div className="album-grid-wrapper">
|
<div className="album-grid-wrapper">
|
||||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
|
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
|
||||||
{loadingMore && (
|
{moreLink && (
|
||||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
|
||||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
|
||||||
<div className="spinner" style={{ width: 24, height: 24 }} />
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!loadingMore && moreLink && (
|
|
||||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||||
<ArrowRight size={24} />
|
<ArrowRight size={24} />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function sanitizeFilename(name: string): string {
|
|||||||
|
|
||||||
export default function ContextMenu() {
|
export default function ContextMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong } = usePlayerStore();
|
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -53,6 +53,9 @@ export default function ContextMenu() {
|
|||||||
|
|
||||||
const { type, item, queueIndex } = contextMenu;
|
const { type, item, queueIndex } = contextMenu;
|
||||||
|
|
||||||
|
const isStarred = (id: string, itemStarred?: string) =>
|
||||||
|
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
|
||||||
|
|
||||||
const handleAction = async (action: () => void | Promise<void>) => {
|
const handleAction = async (action: () => void | Promise<void>) => {
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
await action();
|
await action();
|
||||||
@@ -148,8 +151,13 @@ export default function ContextMenu() {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
<Star size={14} /> {t('contextMenu.favorite')}
|
const starred = isStarred(song.id, song.starred);
|
||||||
|
setStarredOverride(song.id, !starred);
|
||||||
|
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||||
|
})}>
|
||||||
|
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
</div>
|
</div>
|
||||||
{auth.lastfmSessionKey && (() => {
|
{auth.lastfmSessionKey && (() => {
|
||||||
const loveKey = `${song.title}::${song.artist}`;
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
@@ -181,8 +189,13 @@ export default function ContextMenu() {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
||||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
|
const starred = isStarred(album.id, album.starred);
|
||||||
|
setStarredOverride(album.id, !starred);
|
||||||
|
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
|
||||||
|
})}>
|
||||||
|
<Star size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||||
<Download size={14} /> {t('contextMenu.download')}
|
<Download size={14} /> {t('contextMenu.download')}
|
||||||
@@ -199,8 +212,13 @@ export default function ContextMenu() {
|
|||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
<Star size={14} /> {t('contextMenu.favoriteArtist')}
|
const starred = isStarred(artist.id, artist.starred);
|
||||||
|
setStarredOverride(artist.id, !starred);
|
||||||
|
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
|
||||||
|
})}>
|
||||||
|
<Star size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -224,8 +242,13 @@ export default function ContextMenu() {
|
|||||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
<Star size={14} /> {t('contextMenu.favorite')}
|
const starred = isStarred(song.id, song.starred);
|
||||||
|
setStarredOverride(song.id, !starred);
|
||||||
|
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||||
|
})}>
|
||||||
|
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
</div>
|
</div>
|
||||||
{auth.lastfmSessionKey && (() => {
|
{auth.lastfmSessionKey && (() => {
|
||||||
const loveKey = `${song.title}::${song.artist}`;
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,7 +15,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
|
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
|
||||||
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
|
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
|
||||||
<img
|
<img
|
||||||
@@ -23,6 +24,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
|
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
|
||||||
@@ -175,8 +176,8 @@ export default function PlayerBar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* EQ Popup */}
|
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
|
||||||
{eqOpen && (
|
{eqOpen && createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
|
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
|
||||||
<div className="eq-popup">
|
<div className="eq-popup">
|
||||||
@@ -188,7 +189,8 @@ export default function PlayerBar() {
|
|||||||
</div>
|
</div>
|
||||||
<Equalizer />
|
<Equalizer />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
|
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
|
||||||
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
|
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
|
||||||
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
||||||
|
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
||||||
|
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
|
||||||
|
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+24
-4
@@ -30,7 +30,9 @@ const enTranslation = {
|
|||||||
mostPlayed: 'Most Played',
|
mostPlayed: 'Most Played',
|
||||||
discover: 'Discover',
|
discover: 'Discover',
|
||||||
loadMore: 'Load More',
|
loadMore: 'Load More',
|
||||||
discoverMore: 'Discover More'
|
discoverMore: 'Discover More',
|
||||||
|
discoverArtists: 'Discover Artists',
|
||||||
|
discoverArtistsMore: 'All Artists'
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: 'Featured Album',
|
eyebrow: 'Featured Album',
|
||||||
@@ -74,6 +76,9 @@ const enTranslation = {
|
|||||||
favorite: 'Favorite',
|
favorite: 'Favorite',
|
||||||
favoriteArtist: 'Favorite Artist',
|
favoriteArtist: 'Favorite Artist',
|
||||||
favoriteAlbum: 'Favorite Album',
|
favoriteAlbum: 'Favorite Album',
|
||||||
|
unfavorite: 'Remove from Favorites',
|
||||||
|
unfavoriteArtist: 'Remove Artist from Favorites',
|
||||||
|
unfavoriteAlbum: 'Remove Album from Favorites',
|
||||||
removeFromQueue: 'Remove from Queue',
|
removeFromQueue: 'Remove from Queue',
|
||||||
openAlbum: 'Open Album',
|
openAlbum: 'Open Album',
|
||||||
goToArtist: 'Go to Artist',
|
goToArtist: 'Go to Artist',
|
||||||
@@ -551,7 +556,9 @@ const deTranslation = {
|
|||||||
mostPlayed: 'Meistgehört',
|
mostPlayed: 'Meistgehört',
|
||||||
discover: 'Entdecken',
|
discover: 'Entdecken',
|
||||||
loadMore: 'Mehr laden',
|
loadMore: 'Mehr laden',
|
||||||
discoverMore: 'Mehr entdecken'
|
discoverMore: 'Mehr entdecken',
|
||||||
|
discoverArtists: 'Künstler entdecken',
|
||||||
|
discoverArtistsMore: 'Alle Künstler'
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: 'Album des Augenblicks',
|
eyebrow: 'Album des Augenblicks',
|
||||||
@@ -595,6 +602,9 @@ const deTranslation = {
|
|||||||
favorite: 'Favorisieren',
|
favorite: 'Favorisieren',
|
||||||
favoriteArtist: 'Künstler favorisieren',
|
favoriteArtist: 'Künstler favorisieren',
|
||||||
favoriteAlbum: 'Album favorisieren',
|
favoriteAlbum: 'Album favorisieren',
|
||||||
|
unfavorite: 'Aus Favoriten entfernen',
|
||||||
|
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
|
||||||
|
unfavoriteAlbum: 'Album aus Favoriten entfernen',
|
||||||
removeFromQueue: 'Diesen Song entfernen',
|
removeFromQueue: 'Diesen Song entfernen',
|
||||||
openAlbum: 'Album öffnen',
|
openAlbum: 'Album öffnen',
|
||||||
goToArtist: 'Zum Künstler',
|
goToArtist: 'Zum Künstler',
|
||||||
@@ -1072,7 +1082,9 @@ const frTranslation = {
|
|||||||
mostPlayed: 'Les plus écoutés',
|
mostPlayed: 'Les plus écoutés',
|
||||||
discover: 'Découvrir',
|
discover: 'Découvrir',
|
||||||
loadMore: 'Charger plus',
|
loadMore: 'Charger plus',
|
||||||
discoverMore: 'Découvrir plus'
|
discoverMore: 'Découvrir plus',
|
||||||
|
discoverArtists: 'Découvrir des artistes',
|
||||||
|
discoverArtistsMore: 'Tous les artistes'
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: 'Album en vedette',
|
eyebrow: 'Album en vedette',
|
||||||
@@ -1116,6 +1128,9 @@ const frTranslation = {
|
|||||||
favorite: 'Favori',
|
favorite: 'Favori',
|
||||||
favoriteArtist: 'Artiste favori',
|
favoriteArtist: 'Artiste favori',
|
||||||
favoriteAlbum: 'Album favori',
|
favoriteAlbum: 'Album favori',
|
||||||
|
unfavorite: 'Retirer des favoris',
|
||||||
|
unfavoriteArtist: 'Retirer l\'artiste des favoris',
|
||||||
|
unfavoriteAlbum: 'Retirer l\'album des favoris',
|
||||||
removeFromQueue: 'Retirer de la file',
|
removeFromQueue: 'Retirer de la file',
|
||||||
openAlbum: 'Ouvrir l\'album',
|
openAlbum: 'Ouvrir l\'album',
|
||||||
goToArtist: 'Aller à l\'artiste',
|
goToArtist: 'Aller à l\'artiste',
|
||||||
@@ -1593,7 +1608,9 @@ const nlTranslation = {
|
|||||||
mostPlayed: 'Meest gespeeld',
|
mostPlayed: 'Meest gespeeld',
|
||||||
discover: 'Ontdekken',
|
discover: 'Ontdekken',
|
||||||
loadMore: 'Meer laden',
|
loadMore: 'Meer laden',
|
||||||
discoverMore: 'Meer ontdekken'
|
discoverMore: 'Meer ontdekken',
|
||||||
|
discoverArtists: 'Artiesten ontdekken',
|
||||||
|
discoverArtistsMore: 'Alle artiesten'
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: 'Uitgelicht album',
|
eyebrow: 'Uitgelicht album',
|
||||||
@@ -1637,6 +1654,9 @@ const nlTranslation = {
|
|||||||
favorite: 'Favoriet',
|
favorite: 'Favoriet',
|
||||||
favoriteArtist: 'Favoriete artiest',
|
favoriteArtist: 'Favoriete artiest',
|
||||||
favoriteAlbum: 'Favoriet album',
|
favoriteAlbum: 'Favoriet album',
|
||||||
|
unfavorite: 'Verwijderen uit favorieten',
|
||||||
|
unfavoriteArtist: 'Artiest uit favorieten verwijderen',
|
||||||
|
unfavoriteAlbum: 'Album uit favorieten verwijderen',
|
||||||
removeFromQueue: 'Uit wachtrij verwijderen',
|
removeFromQueue: 'Uit wachtrij verwijderen',
|
||||||
openAlbum: 'Album openen',
|
openAlbum: 'Album openen',
|
||||||
goToArtist: 'Naar artiest',
|
goToArtist: 'Naar artiest',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default function AlbumDetail() {
|
|||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
|
|
||||||
@@ -218,7 +219,10 @@ export default function AlbumDetail() {
|
|||||||
hoveredSongId={hoveredSongId}
|
hoveredSongId={hoveredSongId}
|
||||||
setHoveredSongId={setHoveredSongId}
|
setHoveredSongId={setHoveredSongId}
|
||||||
ratings={ratings}
|
ratings={ratings}
|
||||||
starredSongs={starredSongs}
|
starredSongs={new Set([
|
||||||
|
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||||
|
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||||
|
])}
|
||||||
onPlaySong={handlePlaySong}
|
onPlaySong={handlePlaySong}
|
||||||
onRate={handleRate}
|
onRate={handleRate}
|
||||||
onToggleSongStar={toggleSongStar}
|
onToggleSongStar={toggleSongStar}
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ export default function ArtistDetail() {
|
|||||||
const track = {
|
const track = {
|
||||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
|
||||||
};
|
};
|
||||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function Favorites() {
|
|||||||
const track = {
|
const track = {
|
||||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
|
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
|
||||||
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+45
-19
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Hero from '../components/Hero';
|
import Hero from '../components/Hero';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -10,20 +11,29 @@ export default function Home() {
|
|||||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||||
|
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getAlbumList('starred', 12).catch(() => []),
|
getAlbumList('starred', 12).catch(() => []),
|
||||||
getAlbumList('newest', 12).catch(() => []),
|
getAlbumList('newest', 12).catch(() => []),
|
||||||
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
|
getAlbumList('random', 20).catch(() => []),
|
||||||
getAlbumList('frequent', 12).catch(() => []),
|
getAlbumList('frequent', 12).catch(() => []),
|
||||||
]).then(([s, n, r, f]) => {
|
getArtists().catch(() => []),
|
||||||
|
]).then(([s, n, r, f, artists]) => {
|
||||||
setStarred(s);
|
setStarred(s);
|
||||||
setRecent(n);
|
setRecent(n);
|
||||||
setHeroAlbums(r.slice(0, 8));
|
setHeroAlbums(r.slice(0, 8));
|
||||||
setRandom(r.slice(8));
|
setRandom(r.slice(8));
|
||||||
setMostPlayed(f);
|
setMostPlayed(f);
|
||||||
|
// Pick 16 random artists via Fisher-Yates shuffle
|
||||||
|
const shuffled = [...artists];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
setRandomArtists(shuffled.slice(0, 16));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}).catch(() => setLoading(false));
|
}).catch(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -35,17 +45,15 @@ export default function Home() {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const more = await getAlbumList(type, 12, currentList.length);
|
const more = await getAlbumList(type, 12, currentList.length);
|
||||||
// Ensure we don't append duplicates if the API returns them
|
|
||||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||||
if (newItems.length > 0) {
|
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||||
setter(prev => [...prev, ...newItems]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load more', e);
|
console.error('Failed to load more', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
@@ -58,6 +66,36 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<AlbumRow
|
||||||
|
title={t('home.recent')}
|
||||||
|
albums={recent}
|
||||||
|
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||||
|
moreText={t('home.loadMore')}
|
||||||
|
/>
|
||||||
|
<AlbumRow
|
||||||
|
title={t('home.discover')}
|
||||||
|
albums={random}
|
||||||
|
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||||
|
moreText={t('home.discoverMore')}
|
||||||
|
/>
|
||||||
|
{randomArtists.length > 0 && (
|
||||||
|
<section className="album-row-section">
|
||||||
|
<div className="album-row-header">
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{randomArtists.map(a => (
|
||||||
|
<button key={a.id} className="artist-ext-link" onClick={() => navigate(`/artist/${a.id}`)}>
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="artist-ext-link" onClick={() => navigate('/artists')}
|
||||||
|
style={{ opacity: 0.6 }}>
|
||||||
|
{t('home.discoverArtistsMore')} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{starred.length > 0 && (
|
{starred.length > 0 && (
|
||||||
<AlbumRow
|
<AlbumRow
|
||||||
title={t('home.starred')}
|
title={t('home.starred')}
|
||||||
@@ -66,24 +104,12 @@ export default function Home() {
|
|||||||
moreText={t('home.loadMore')}
|
moreText={t('home.loadMore')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AlbumRow
|
|
||||||
title={t('home.recent')}
|
|
||||||
albums={recent}
|
|
||||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
|
||||||
moreText={t('home.loadMore')}
|
|
||||||
/>
|
|
||||||
<AlbumRow
|
<AlbumRow
|
||||||
title={t('home.mostPlayed')}
|
title={t('home.mostPlayed')}
|
||||||
albums={mostPlayed}
|
albums={mostPlayed}
|
||||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||||
moreText={t('home.loadMore')}
|
moreText={t('home.loadMore')}
|
||||||
/>
|
/>
|
||||||
<AlbumRow
|
|
||||||
title={t('home.discover')}
|
|
||||||
albums={random}
|
|
||||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
|
||||||
moreText={t('home.discoverMore')}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -173,14 +173,6 @@ export default function NowPlaying() {
|
|||||||
|
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const setQueueVisible = usePlayerStore(s => s.setQueueVisible);
|
|
||||||
|
|
||||||
// Hide queue panel while on this page, restore on leave
|
|
||||||
useEffect(() => {
|
|
||||||
const wasVisible = usePlayerStore.getState().isQueueVisible;
|
|
||||||
if (wasVisible) setQueueVisible(false);
|
|
||||||
return () => { if (wasVisible) setQueueVisible(true); };
|
|
||||||
}, [setQueueVisible]);
|
|
||||||
|
|
||||||
// Extra song metadata
|
// Extra song metadata
|
||||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export default function RandomMix() {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{/* Left: Blacklist */}
|
{/* Left: Blacklist */}
|
||||||
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
{t('randomMix.filterPanelTitle')}
|
{t('randomMix.filterPanelTitle')}
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +291,7 @@ export default function RandomMix() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Genre Mix */}
|
{/* Right: Genre Mix */}
|
||||||
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
{t('randomMix.genreMixTitle')}
|
{t('randomMix.genreMixTitle')}
|
||||||
</div>
|
</div>
|
||||||
@@ -342,7 +342,7 @@ export default function RandomMix() {
|
|||||||
{genreMixSongs.map(song => (
|
{genreMixSongs.map(song => (
|
||||||
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||||
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
|
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
|
||||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }, 'song'); }}
|
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }, 'song'); }}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
|
||||||
@@ -391,7 +391,7 @@ export default function RandomMix() {
|
|||||||
draggable
|
draggable
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating };
|
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred };
|
||||||
setContextMenuSongId(song.id);
|
setContextMenuSongId(song.id);
|
||||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface Track {
|
|||||||
replayGainTrackDb?: number;
|
replayGainTrackDb?: number;
|
||||||
replayGainAlbumDb?: number;
|
replayGainAlbumDb?: number;
|
||||||
replayGainPeak?: number;
|
replayGainPeak?: number;
|
||||||
|
starred?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function songToTrack(song: SubsonicSong): Track {
|
export function songToTrack(song: SubsonicSong): Track {
|
||||||
@@ -43,6 +44,7 @@ export function songToTrack(song: SubsonicSong): Track {
|
|||||||
replayGainTrackDb: song.replayGain?.trackGain,
|
replayGainTrackDb: song.replayGain?.trackGain,
|
||||||
replayGainAlbumDb: song.replayGain?.albumGain,
|
replayGainAlbumDb: song.replayGain?.albumGain,
|
||||||
replayGainPeak: song.replayGain?.trackPeak,
|
replayGainPeak: song.replayGain?.trackPeak,
|
||||||
|
starred: song.starred,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,8 @@ interface PlayerState {
|
|||||||
scrobbled: boolean;
|
scrobbled: boolean;
|
||||||
lastfmLoved: boolean;
|
lastfmLoved: boolean;
|
||||||
lastfmLovedCache: Record<string, boolean>;
|
lastfmLovedCache: Record<string, boolean>;
|
||||||
|
starredOverrides: Record<string, boolean>;
|
||||||
|
setStarredOverride: (id: string, starred: boolean) => void;
|
||||||
|
|
||||||
playTrack: (track: Track, queue?: Track[]) => void;
|
playTrack: (track: Track, queue?: Track[]) => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
@@ -277,6 +281,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
lastfmLoved: false,
|
lastfmLoved: false,
|
||||||
lastfmLovedCache: {},
|
lastfmLovedCache: {},
|
||||||
|
starredOverrides: {},
|
||||||
|
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
|
||||||
isQueueVisible: true,
|
isQueueVisible: true,
|
||||||
isFullscreenOpen: false,
|
isFullscreenOpen: false,
|
||||||
repeatMode: 'off',
|
repeatMode: 'off',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
|
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light' | 'spotless' | 'dzr0' | 'cupertino-beats';
|
||||||
|
|
||||||
interface ThemeState {
|
interface ThemeState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
|||||||
@@ -435,6 +435,7 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.album-card-info {
|
.album-card-info {
|
||||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||||
}
|
}
|
||||||
@@ -664,16 +665,17 @@
|
|||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
background: rgba(0, 0, 0, 0.88);
|
background: rgba(0, 0, 0, 0.88);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 8vh;
|
padding: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-lightbox-img {
|
.cover-lightbox-img {
|
||||||
max-width: 90vw;
|
max-width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8);
|
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8);
|
||||||
|
|||||||
@@ -576,7 +576,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
border-bottom: 1px solid var(--bg-surface);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1249,6 +1249,327 @@
|
|||||||
--player-height: 88px;
|
--player-height: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Spotless ─── */
|
||||||
|
[data-theme='spotless'] {
|
||||||
|
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%23B3B3B3%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-crust: #000000;
|
||||||
|
--ctp-mantle: #121212;
|
||||||
|
--ctp-base: #181818;
|
||||||
|
--ctp-surface0: #282828;
|
||||||
|
--ctp-surface1: #3E3E3E;
|
||||||
|
--ctp-surface2: #535353;
|
||||||
|
--ctp-overlay0: #727272;
|
||||||
|
--ctp-overlay1: #A7A7A7;
|
||||||
|
--ctp-overlay2: #B3B3B3;
|
||||||
|
--ctp-text: #FFFFFF;
|
||||||
|
--ctp-subtext1: #EBEBEB;
|
||||||
|
--ctp-subtext0: #D6D6D6;
|
||||||
|
|
||||||
|
--ctp-mauve: #1ED760;
|
||||||
|
--ctp-lavender: #1FDF64;
|
||||||
|
--ctp-green: #1ED760;
|
||||||
|
--ctp-teal: #1DF2A4;
|
||||||
|
--ctp-sky: #3D91F4;
|
||||||
|
--ctp-blue: #2E77D0;
|
||||||
|
--ctp-sapphire: #1C5599;
|
||||||
|
--ctp-pink: #E55C5C;
|
||||||
|
--ctp-flamingo: #E55C5C;
|
||||||
|
--ctp-rosewater:#FFC8C8;
|
||||||
|
--ctp-yellow: #FFA42B;
|
||||||
|
--ctp-peach: #FF7A00;
|
||||||
|
--ctp-maroon: #B22A2A;
|
||||||
|
--ctp-red: #E22134;
|
||||||
|
|
||||||
|
--bg-app: #121212;
|
||||||
|
--bg-sidebar: #000000;
|
||||||
|
--bg-card: #181818;
|
||||||
|
--bg-hover: #282828;
|
||||||
|
--bg-player: #181818;
|
||||||
|
--bg-glass: rgba(18, 18, 18, 0.85);
|
||||||
|
|
||||||
|
--accent: #1ED760;
|
||||||
|
--accent-dim: rgba(30, 215, 96, 0.15);
|
||||||
|
--accent-glow: rgba(30, 215, 96, 0.35);
|
||||||
|
--volume-accent: #FFFFFF;
|
||||||
|
|
||||||
|
--text-primary: #FFFFFF;
|
||||||
|
--text-secondary:#B3B3B3;
|
||||||
|
--text-muted: #A7A7A7;
|
||||||
|
|
||||||
|
--border: #282828;
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--positive: #1ED760;
|
||||||
|
--warning: #FFA42B;
|
||||||
|
--danger: #E22134;
|
||||||
|
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.5);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.6);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.7);
|
||||||
|
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-med: 250ms ease;
|
||||||
|
--transition-slow: 350ms ease;
|
||||||
|
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--player-height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .sidebar {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .player-bar {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .nav-link.active {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .nav-link.active::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .btn-primary {
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .player-btn-primary {
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .player-btn-primary:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='spotless'] .queue-item:hover,
|
||||||
|
[data-theme='spotless'] .track-row:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Cupertino Beats ─── */
|
||||||
|
[data-theme='cupertino-beats'] {
|
||||||
|
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%23a1a1a1%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-crust: #000000;
|
||||||
|
--ctp-mantle: #121212;
|
||||||
|
--ctp-base: #1c1c1e;
|
||||||
|
--ctp-surface0: #2c2c2e;
|
||||||
|
--ctp-surface1: #3a3a3c;
|
||||||
|
--ctp-surface2: #48484a;
|
||||||
|
--ctp-overlay0: #636366;
|
||||||
|
--ctp-overlay1: #8e8e93;
|
||||||
|
--ctp-overlay2: #aeaeb2;
|
||||||
|
--ctp-text: #ffffff;
|
||||||
|
--ctp-subtext1: #ebebf599;
|
||||||
|
--ctp-subtext0: #ebebf560;
|
||||||
|
|
||||||
|
--ctp-mauve: #fa243c;
|
||||||
|
--ctp-lavender: #ff5e71;
|
||||||
|
--ctp-green: #34c759;
|
||||||
|
--ctp-teal: #30b0c7;
|
||||||
|
--ctp-sky: #5ac8fa;
|
||||||
|
--ctp-blue: #007aff;
|
||||||
|
--ctp-sapphire: #0051d5;
|
||||||
|
--ctp-pink: #ff2d55;
|
||||||
|
--ctp-flamingo: #ff3b30;
|
||||||
|
--ctp-rosewater:#ff9f0a;
|
||||||
|
--ctp-yellow: #ffcc00;
|
||||||
|
--ctp-peach: #ff9500;
|
||||||
|
--ctp-maroon: #af52de;
|
||||||
|
--ctp-red: #ff3b30;
|
||||||
|
|
||||||
|
--bg-app: #1c1c1e;
|
||||||
|
--bg-sidebar: rgba(18, 18, 18, 0.75);
|
||||||
|
--bg-card: #2c2c2e;
|
||||||
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
|
--bg-player: rgba(0, 0, 0, 0.85);
|
||||||
|
--bg-glass: rgba(28, 28, 30, 0.6);
|
||||||
|
|
||||||
|
--accent: #fa243c;
|
||||||
|
--accent-dim: rgba(250, 36, 60, 0.15);
|
||||||
|
--accent-glow: rgba(250, 36, 60, 0.3);
|
||||||
|
--volume-accent: #fa243c;
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary:rgba(235, 235, 245, 0.6);
|
||||||
|
--text-muted: rgba(235, 235, 245, 0.3);
|
||||||
|
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
--positive: #34c759;
|
||||||
|
--warning: #ffcc00;
|
||||||
|
--danger: #ff3b30;
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.5);
|
||||||
|
--shadow-md: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
|
||||||
|
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-med: 250ms ease;
|
||||||
|
--transition-slow: 350ms ease;
|
||||||
|
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--player-height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='cupertino-beats'] .sidebar {
|
||||||
|
backdrop-filter: blur(40px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='cupertino-beats'] .player-bar {
|
||||||
|
backdrop-filter: blur(30px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(30px) saturate(150%);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='cupertino-beats'] .nav-link.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='cupertino-beats'] .nav-link.active::before {
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dzr0 ─── */
|
||||||
|
[data-theme='dzr0'] {
|
||||||
|
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%23A238FF%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-crust: #E5E5E9;
|
||||||
|
--ctp-mantle: #F5F5F7;
|
||||||
|
--ctp-base: #FFFFFF;
|
||||||
|
--ctp-surface0: #F0F0F3;
|
||||||
|
--ctp-surface1: #E8E8EC;
|
||||||
|
--ctp-surface2: #D8D8DF;
|
||||||
|
--ctp-overlay0: #9E9EAF;
|
||||||
|
--ctp-overlay1: #7C7C8F;
|
||||||
|
--ctp-overlay2: #5D5D70;
|
||||||
|
--ctp-text: #121216;
|
||||||
|
--ctp-subtext1: #4A4A5A;
|
||||||
|
--ctp-subtext0: #6A6A7A;
|
||||||
|
|
||||||
|
--ctp-mauve: #A238FF;
|
||||||
|
--ctp-lavender: #B86BFF;
|
||||||
|
--ctp-green: #00C171;
|
||||||
|
--ctp-teal: #00C171;
|
||||||
|
--ctp-sky: #3D91F4;
|
||||||
|
--ctp-blue: #2E77D0;
|
||||||
|
--ctp-sapphire: #1C5599;
|
||||||
|
--ctp-pink: #EF3B5D;
|
||||||
|
--ctp-flamingo: #EF3B5D;
|
||||||
|
--ctp-rosewater:#FFC8C8;
|
||||||
|
--ctp-yellow: #FFCC00;
|
||||||
|
--ctp-peach: #FF7A00;
|
||||||
|
--ctp-maroon: #B22A2A;
|
||||||
|
--ctp-red: #EF3B5D;
|
||||||
|
|
||||||
|
--bg-app: #FFFFFF;
|
||||||
|
--bg-sidebar: #F5F5F7;
|
||||||
|
--bg-card: #FBFBFC;
|
||||||
|
--bg-hover: #F0F0F3;
|
||||||
|
--bg-player: #FFFFFF;
|
||||||
|
--bg-glass: rgba(255, 255, 255, 0.85);
|
||||||
|
|
||||||
|
--accent: #A238FF;
|
||||||
|
--accent-dim: rgba(162, 56, 255, 0.12);
|
||||||
|
--accent-glow: rgba(162, 56, 255, 0.25);
|
||||||
|
--volume-accent: #A238FF;
|
||||||
|
|
||||||
|
--text-primary: #121216;
|
||||||
|
--text-secondary:#4A4A5A;
|
||||||
|
--text-muted: #8E8E9F;
|
||||||
|
|
||||||
|
--border: #E2E2E9;
|
||||||
|
--border-subtle: #F0F0F5;
|
||||||
|
|
||||||
|
--positive: #00C171;
|
||||||
|
--warning: #FFCC00;
|
||||||
|
--danger: #EF3B5D;
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.10);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-med: 250ms ease;
|
||||||
|
--transition-slow: 350ms ease;
|
||||||
|
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--player-height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dzr0'] .player-bar {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
box-shadow: 0 -4px 12px rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dzr0'] .player-track-name,
|
||||||
|
[data-theme='dzr0'] .player-time,
|
||||||
|
[data-theme='dzr0'] .player-track-artist {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dzr0'] .sidebar {
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dzr0'] .nav-link:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dzr0'] .nav-link.active {
|
||||||
|
background-color: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dzr0'] .btn-primary {
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Reset ─── */
|
/* ─── Reset ─── */
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
Reference in New Issue
Block a user