mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: v1.20.0 — FLAC Seek Fix, Genres, Genre Filter, Chinese, W10 Theme
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.20.0] - 2026-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Chinese language (zh)**: Full UI translation contributed by [@jiezhuo](https://github.com/jiezhuo). Language can be selected in Settings → General.
|
||||||
|
- **Genres page** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: New page (sidebar: Tags icon) showing all server genres as coloured cards — icon watermark, genre name, album count. Cards are sorted by album count descending and deterministically colour-coded from the Catppuccin palette. Clicking a card opens the album list for that genre. Navigating back restores the previous scroll position.
|
||||||
|
- **Genre filter on Albums, New Releases, Random Albums** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: A multi-select genre combobox in the page header lets you filter any of these views to one or more genres. Chips show selected genres; backspace removes the last one; clicking outside collapses the filter automatically when nothing is selected. In filter mode, results are fetched in parallel across all selected genres and deduped client-side.
|
||||||
|
- **Settings — Contributors**: A new "Contributors" row in the About section credits community translators.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Theme — W10** *(Operating Systems)*: New Windows 10 Fluent Design light theme. Clean white content area, flat light-grey `#F3F3F3` navigation pane, near-black `#1C1C1C` taskbar player bar with a Windows-blue `#0078D4` accent stripe, flat buttons without gradients (4 px radius). Sharp, unmistakably W10 — distinct from the glass-era W7/Vista and the rounded-corner W11.
|
||||||
|
- **ThemePicker — Windows themes sorted by release year**: W3.1 → W98 → WXP → Wista → W7 → W10 → W11.
|
||||||
|
- **Playlists page — removed**: The dedicated Playlists page has been removed. Playlists remain fully accessible via the Queue panel (Save / Load buttons in the toolbar).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **FLAC seeking** *(Rust audio engine)*: `rodio`'s internal `ReadSeekSource` hardcodes `byte_len() → None`, which caused the symphonia FLAC demuxer to reject all seek attempts (it validates seek byte offsets against the total stream length). Replaced `rodio::Decoder` with a direct symphonia pipeline (`SizedDecoder`) that wraps the audio bytes in a `SizedCursorSource` providing the correct `byte_len()`. FLAC seeking now works regardless of whether the file has an embedded SEEKTABLE.
|
||||||
|
- **Genre missing in Queue meta box when playing from album card**: `playAlbum()` (used by the play button on all album cards) mapped song-level genre only — which Navidrome does not always return per song. Now falls back to the album-level genre from `getAlbum`. Same fallback applied to all three play/enqueue handlers in `AlbumDetail`.
|
||||||
|
- **Logo gradient CSS variables**: Sidebar logo gradient now uses `--logo-color-start` / `--logo-color-end` with fallbacks, allowing themes with dark sidebars to override the gradient colours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.19.0] - 2026-03-27
|
## [1.19.0] - 2026-03-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
|
|
||||||
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
|
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
|
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
|
||||||
@@ -36,6 +36,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||||
|
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
- [x] IndexedDB image caching
|
- [x] IndexedDB image caching
|
||||||
- [x] Random Mix with keyword filter & Super Genre mix
|
- [x] Random Mix with keyword filter & Super Genre mix
|
||||||
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||||
- [x] Internationalization (English, German, French, Dutch)
|
- [x] Internationalization (English, German, French, Dutch, Chinese)
|
||||||
- [x] AUR package (Arch / CachyOS)
|
- [x] AUR package (Arch / CachyOS)
|
||||||
- [x] Configurable keybindings
|
- [x] Configurable keybindings
|
||||||
- [x] Font picker (10 UI fonts)
|
- [x] Font picker (10 UI fonts)
|
||||||
@@ -71,7 +72,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
## ● Known Limitations
|
## ● Known Limitations
|
||||||
|
|
||||||
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
||||||
- **FLAC seeking**: Seeking in FLAC files requires an embedded SEEKTABLE metadata block. Files encoded without one cannot be seeked — clicking the waveform has no effect. Most modern encoders include a SEEKTABLE by default. You can add one retroactively with `metaflac --add-seekpoint=10s *.flac`.
|
|
||||||
|
|
||||||
## 📥 Installation
|
## 📥 Installation
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.19.0",
|
"version": "1.20.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1
@@ -3370,6 +3370,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"souvlaki",
|
"souvlaki",
|
||||||
|
"symphonia",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ tauri-plugin-fs = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||||
|
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] }
|
||||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||||
md5 = "0.7"
|
md5 = "0.7"
|
||||||
tokio = { version = "1", features = ["rt", "time"] }
|
tokio = { version = "1", features = ["rt", "time"] }
|
||||||
|
|||||||
+297
-10
@@ -1,12 +1,21 @@
|
|||||||
use std::io::Cursor;
|
use std::io::{Cursor, Read, Seek};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
||||||
use rodio::{Decoder, Sink, Source};
|
use rodio::{Sink, Source};
|
||||||
use rodio::source::UniformSourceIterator;
|
use rodio::source::UniformSourceIterator;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use symphonia::core::{
|
||||||
|
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||||
|
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||||
|
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||||
|
io::{MediaSource, MediaSourceStream},
|
||||||
|
meta::MetadataOptions,
|
||||||
|
probe::Hint,
|
||||||
|
units::{self, Time},
|
||||||
|
};
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||||
@@ -394,6 +403,264 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── SizedCursorSource — MediaSource with correct byte_len ────────────────────
|
||||||
|
//
|
||||||
|
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
|
||||||
|
// byte_len() → None. This tells symphonia "stream length unknown", which
|
||||||
|
// prevents the FLAC demuxer from seeking (it validates seek offsets against
|
||||||
|
// the total stream length from byte_len). MP3 is unaffected because its
|
||||||
|
// demuxer uses Xing/LAME headers instead.
|
||||||
|
//
|
||||||
|
// This wrapper provides the actual byte length, fixing seek for all formats.
|
||||||
|
|
||||||
|
struct SizedCursorSource {
|
||||||
|
inner: Cursor<Vec<u8>>,
|
||||||
|
len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for SizedCursorSource {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
self.inner.read(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for SizedCursorSource {
|
||||||
|
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||||
|
self.inner.seek(pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSource for SizedCursorSource {
|
||||||
|
fn is_seekable(&self) -> bool { true }
|
||||||
|
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
|
||||||
|
//
|
||||||
|
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
|
||||||
|
// (byte_len = None). This constructs the symphonia pipeline directly,
|
||||||
|
// providing the correct byte_len via SizedCursorSource.
|
||||||
|
//
|
||||||
|
// Implements Iterator<Item = i16> + Source — identical interface to
|
||||||
|
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||||||
|
|
||||||
|
const DECODE_MAX_RETRIES: usize = 3;
|
||||||
|
|
||||||
|
struct SizedDecoder {
|
||||||
|
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||||||
|
current_frame_offset: usize,
|
||||||
|
format: Box<dyn FormatReader>,
|
||||||
|
total_duration: Option<Time>,
|
||||||
|
buffer: SampleBuffer<i16>,
|
||||||
|
spec: SignalSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SizedDecoder {
|
||||||
|
fn new(data: Vec<u8>, format_hint: Option<&str>) -> Result<Self, String> {
|
||||||
|
let data_len = data.len() as u64;
|
||||||
|
let source = SizedCursorSource {
|
||||||
|
inner: Cursor::new(data),
|
||||||
|
len: data_len,
|
||||||
|
};
|
||||||
|
let mss = MediaSourceStream::new(
|
||||||
|
Box::new(source) as Box<dyn MediaSource>,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hint = Hint::new();
|
||||||
|
if let Some(ext) = format_hint {
|
||||||
|
hint.with_extension(ext);
|
||||||
|
}
|
||||||
|
let format_opts = FormatOptions {
|
||||||
|
enable_gapless: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let probed = symphonia::default::get_probe()
|
||||||
|
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||||
|
.map_err(|e| format!("probe failed: {e}"))?;
|
||||||
|
|
||||||
|
let track = probed.format
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||||
|
.ok_or_else(|| "no supported audio track".to_string())?;
|
||||||
|
|
||||||
|
let track_id = track.id;
|
||||||
|
let total_duration = track.codec_params.time_base
|
||||||
|
.zip(track.codec_params.n_frames)
|
||||||
|
.map(|(base, frames)| base.calc_time(frames));
|
||||||
|
|
||||||
|
let mut decoder = symphonia::default::get_codecs()
|
||||||
|
.make(&track.codec_params, &DecoderOptions::default())
|
||||||
|
.map_err(|e| format!("codec init failed: {e}"))?;
|
||||||
|
|
||||||
|
let mut format = probed.format;
|
||||||
|
|
||||||
|
// Decode the first packet to initialise spec + buffer.
|
||||||
|
let mut decode_errors: usize = 0;
|
||||||
|
let decoded = loop {
|
||||||
|
let packet = match format.next_packet() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||||
|
break decoder.last_decoded();
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("first packet: {e}")),
|
||||||
|
};
|
||||||
|
if packet.track_id() != track_id { continue; }
|
||||||
|
match decoder.decode(&packet) {
|
||||||
|
Ok(decoded) => break decoded,
|
||||||
|
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||||
|
decode_errors += 1;
|
||||||
|
if decode_errors > DECODE_MAX_RETRIES {
|
||||||
|
return Err("too many decode errors".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("decode: {e}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let spec = decoded.spec().to_owned();
|
||||||
|
let buffer = Self::make_buffer(decoded, &spec);
|
||||||
|
|
||||||
|
Ok(SizedDecoder {
|
||||||
|
decoder,
|
||||||
|
current_frame_offset: 0,
|
||||||
|
format,
|
||||||
|
total_duration,
|
||||||
|
buffer,
|
||||||
|
spec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
|
||||||
|
let duration = units::Duration::from(decoded.capacity() as u64);
|
||||||
|
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
|
||||||
|
buffer.copy_interleaved_ref(decoded);
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine position after a coarse seek — decode packets until we reach the
|
||||||
|
/// exact requested timestamp.
|
||||||
|
fn refine_position(
|
||||||
|
&mut self,
|
||||||
|
seek_res: symphonia::core::formats::SeekedTo,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
|
||||||
|
let packet = loop {
|
||||||
|
let candidate = self.format.next_packet()
|
||||||
|
.map_err(|e| format!("refine seek: {e}"))?;
|
||||||
|
if candidate.dur() > samples_to_pass {
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
samples_to_pass -= candidate.dur();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decoded = self.decoder.decode(&packet);
|
||||||
|
for _ in 0..DECODE_MAX_RETRIES {
|
||||||
|
if decoded.is_err() {
|
||||||
|
let p = self.format.next_packet()
|
||||||
|
.map_err(|e| format!("refine retry: {e}"))?;
|
||||||
|
decoded = self.decoder.decode(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
|
||||||
|
decoded.spec().clone_into(&mut self.spec);
|
||||||
|
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||||
|
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for SizedDecoder {
|
||||||
|
type Item = i16;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn next(&mut self) -> Option<i16> {
|
||||||
|
if self.current_frame_offset >= self.buffer.len() {
|
||||||
|
let packet = self.format.next_packet().ok()?;
|
||||||
|
let mut decoded = self.decoder.decode(&packet);
|
||||||
|
for _ in 0..DECODE_MAX_RETRIES {
|
||||||
|
if decoded.is_err() {
|
||||||
|
let p = self.format.next_packet().ok()?;
|
||||||
|
decoded = self.decoder.decode(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let decoded = decoded.ok()?;
|
||||||
|
decoded.spec().clone_into(&mut self.spec);
|
||||||
|
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||||
|
self.current_frame_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||||||
|
self.current_frame_offset += 1;
|
||||||
|
Some(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source for SizedDecoder {
|
||||||
|
#[inline]
|
||||||
|
fn current_frame_len(&self) -> Option<usize> {
|
||||||
|
Some(self.buffer.samples().len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn channels(&self) -> u16 {
|
||||||
|
self.spec.channels.count() as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn sample_rate(&self) -> u32 {
|
||||||
|
self.spec.rate
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn total_duration(&self) -> Option<Duration> {
|
||||||
|
self.total_duration.map(|Time { seconds, frac }| {
|
||||||
|
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||||
|
let seek_beyond_end = self
|
||||||
|
.total_duration()
|
||||||
|
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
|
||||||
|
|
||||||
|
let time: Time = if seek_beyond_end {
|
||||||
|
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
|
||||||
|
// Step back a tiny bit — some demuxers can't seek to the exact end.
|
||||||
|
let mut secs = t.seconds;
|
||||||
|
let mut frac = t.frac - 0.0001;
|
||||||
|
if frac < 0.0 {
|
||||||
|
secs = secs.saturating_sub(1);
|
||||||
|
frac = 1.0 - frac;
|
||||||
|
}
|
||||||
|
Time { seconds: secs, frac }
|
||||||
|
} else {
|
||||||
|
pos.as_secs_f64().into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let to_skip = self.current_frame_offset % self.channels() as usize;
|
||||||
|
|
||||||
|
let seek_res = self
|
||||||
|
.format
|
||||||
|
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||||
|
.map_err(|e| rodio::source::SeekError::Other(
|
||||||
|
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
||||||
|
))?;
|
||||||
|
|
||||||
|
self.refine_position(seek_res)
|
||||||
|
.map_err(|e| rodio::source::SeekError::Other(
|
||||||
|
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||||
|
))?;
|
||||||
|
|
||||||
|
self.current_frame_offset += to_skip;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
||||||
@@ -473,6 +740,7 @@ struct BuiltSource {
|
|||||||
///
|
///
|
||||||
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
|
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
|
||||||
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
|
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
|
||||||
|
/// `format_hint`: optional file extension (e.g. "flac", "mp3") to help symphonia probe.
|
||||||
fn build_source(
|
fn build_source(
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
duration_hint: f64,
|
duration_hint: f64,
|
||||||
@@ -482,11 +750,11 @@ fn build_source(
|
|||||||
fade_in_dur: Duration,
|
fade_in_dur: Duration,
|
||||||
sample_counter: Arc<AtomicU64>,
|
sample_counter: Arc<AtomicU64>,
|
||||||
target_rate: u32,
|
target_rate: u32,
|
||||||
|
format_hint: Option<&str>,
|
||||||
) -> Result<BuiltSource, String> {
|
) -> Result<BuiltSource, String> {
|
||||||
let gapless = parse_gapless_info(&data);
|
let gapless = parse_gapless_info(&data);
|
||||||
|
|
||||||
let cursor = Cursor::new(data);
|
let decoder = SizedDecoder::new(data, format_hint)?;
|
||||||
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
|
|
||||||
let sample_rate = decoder.sample_rate();
|
let sample_rate = decoder.sample_rate();
|
||||||
let channels = decoder.channels();
|
let channels = decoder.channels();
|
||||||
|
|
||||||
@@ -881,6 +1149,10 @@ pub async fn audio_play(
|
|||||||
// Reset sample counter for the new track.
|
// Reset sample counter for the new track.
|
||||||
state.samples_played.store(0, Ordering::Relaxed);
|
state.samples_played.store(0, Ordering::Relaxed);
|
||||||
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||||
|
// Extract format hint from URL for better symphonia probing.
|
||||||
|
let format_hint = url.rsplit('.').next()
|
||||||
|
.and_then(|ext| ext.split('?').next())
|
||||||
|
.map(|s| s.to_lowercase());
|
||||||
let built = build_source(
|
let built = build_source(
|
||||||
data,
|
data,
|
||||||
duration_hint,
|
duration_hint,
|
||||||
@@ -890,6 +1162,7 @@ pub async fn audio_play(
|
|||||||
fade_in_dur,
|
fade_in_dur,
|
||||||
state.samples_played.clone(),
|
state.samples_played.clone(),
|
||||||
target_rate,
|
target_rate,
|
||||||
|
format_hint.as_deref(),
|
||||||
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
||||||
let source = built.source;
|
let source = built.source;
|
||||||
let duration_secs = built.duration_secs;
|
let duration_secs = built.duration_secs;
|
||||||
@@ -1071,6 +1344,9 @@ pub async fn audio_chain_preload(
|
|||||||
// samples_played when the chained track becomes active.
|
// samples_played when the chained track becomes active.
|
||||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||||
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||||
|
let format_hint = url.rsplit('.').next()
|
||||||
|
.and_then(|ext| ext.split('?').next())
|
||||||
|
.map(|s| s.to_lowercase());
|
||||||
let built = build_source(
|
let built = build_source(
|
||||||
data,
|
data,
|
||||||
duration_hint,
|
duration_hint,
|
||||||
@@ -1080,6 +1356,7 @@ pub async fn audio_chain_preload(
|
|||||||
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
||||||
chain_counter.clone(),
|
chain_counter.clone(),
|
||||||
target_rate,
|
target_rate,
|
||||||
|
format_hint.as_deref(),
|
||||||
).map_err(|e| e.to_string())?;
|
).map_err(|e| e.to_string())?;
|
||||||
let source = built.source;
|
let source = built.source;
|
||||||
let duration_secs = built.duration_secs;
|
let duration_secs = built.duration_secs;
|
||||||
@@ -1144,6 +1421,8 @@ fn spawn_progress_task(
|
|||||||
let mut near_end_ticks: u32 = 0;
|
let mut near_end_ticks: u32 = 0;
|
||||||
// Local done-flag reference; swapped on gapless transition.
|
// Local done-flag reference; swapped on gapless transition.
|
||||||
let mut current_done = initial_done;
|
let mut current_done = initial_done;
|
||||||
|
// Local sample counter; swapped to chained source's counter on transition.
|
||||||
|
let mut samples_played = samples_played;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
|
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
|
||||||
@@ -1163,12 +1442,11 @@ fn spawn_progress_task(
|
|||||||
// Swap to the chained source's done flag.
|
// Swap to the chained source's done flag.
|
||||||
current_done = info.source_done;
|
current_done = info.source_done;
|
||||||
|
|
||||||
// Swap the sample counter: the chained source's counter
|
// Swap to the chained source's sample counter.
|
||||||
// is already being incremented by CountingSource. Copy its
|
// The chained CountingSource increments its own Arc,
|
||||||
// current value into the shared samples_played so the
|
// so we must rebind our local reference to it —
|
||||||
// progress calculation stays accurate.
|
// a one-time value copy would go stale immediately.
|
||||||
let chained_samples = info.sample_counter.load(Ordering::Relaxed);
|
samples_played = info.sample_counter;
|
||||||
samples_played.store(chained_samples, Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Update tracking state.
|
// Update tracking state.
|
||||||
{
|
{
|
||||||
@@ -1234,6 +1512,15 @@ fn spawn_progress_task(
|
|||||||
near_end_ticks += 1;
|
near_end_ticks += 1;
|
||||||
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
|
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
|
||||||
if near_end_ticks >= 10 {
|
if near_end_ticks >= 10 {
|
||||||
|
// If a gapless chain is pending, the source hasn't
|
||||||
|
// exhausted yet — duration_hint (integer seconds from
|
||||||
|
// Subsonic) is shorter than the actual audio content.
|
||||||
|
// Don't emit audio:ended; let the gapless transition
|
||||||
|
// handle it when current_done fires.
|
||||||
|
let has_chain = chained_arc.lock().unwrap().is_some();
|
||||||
|
if has_chain {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||||
app.emit("audio:ended", ()).ok();
|
app.emit("audio:ended", ()).ok();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Psysonic",
|
"productName": "Psysonic",
|
||||||
"version": "1.19.0",
|
"version": "1.20.0",
|
||||||
"identifier": "dev.psysonic.player",
|
"identifier": "dev.psysonic.player",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+4
-2
@@ -22,7 +22,6 @@ import Login from './pages/Login';
|
|||||||
import AlbumDetail from './pages/AlbumDetail';
|
import AlbumDetail from './pages/AlbumDetail';
|
||||||
import LabelAlbums from './pages/LabelAlbums';
|
import LabelAlbums from './pages/LabelAlbums';
|
||||||
import Statistics from './pages/Statistics';
|
import Statistics from './pages/Statistics';
|
||||||
import Playlists from './pages/Playlists';
|
|
||||||
import Help from './pages/Help';
|
import Help from './pages/Help';
|
||||||
import RandomAlbums from './pages/RandomAlbums';
|
import RandomAlbums from './pages/RandomAlbums';
|
||||||
import SearchResults from './pages/SearchResults';
|
import SearchResults from './pages/SearchResults';
|
||||||
@@ -36,6 +35,8 @@ import LastfmIndicator from './components/LastfmIndicator';
|
|||||||
import OfflineOverlay from './components/OfflineOverlay';
|
import OfflineOverlay from './components/OfflineOverlay';
|
||||||
import OfflineBanner from './components/OfflineBanner';
|
import OfflineBanner from './components/OfflineBanner';
|
||||||
import OfflineLibrary from './pages/OfflineLibrary';
|
import OfflineLibrary from './pages/OfflineLibrary';
|
||||||
|
import Genres from './pages/Genres';
|
||||||
|
import GenreDetail from './pages/GenreDetail';
|
||||||
import ExportPickerModal from './components/ExportPickerModal';
|
import ExportPickerModal from './components/ExportPickerModal';
|
||||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
@@ -200,7 +201,6 @@ function AppShell() {
|
|||||||
<Route path="/new-releases" element={<NewReleases />} />
|
<Route path="/new-releases" element={<NewReleases />} />
|
||||||
<Route path="/favorites" element={<Favorites />} />
|
<Route path="/favorites" element={<Favorites />} />
|
||||||
<Route path="/random-mix" element={<RandomMix />} />
|
<Route path="/random-mix" element={<RandomMix />} />
|
||||||
<Route path="/playlists" element={<Playlists />} />
|
|
||||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||||
<Route path="/search" element={<SearchResults />} />
|
<Route path="/search" element={<SearchResults />} />
|
||||||
<Route path="/statistics" element={<Statistics />} />
|
<Route path="/statistics" element={<Statistics />} />
|
||||||
@@ -208,6 +208,8 @@ function AppShell() {
|
|||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/help" element={<Help />} />
|
<Route path="/help" element={<Help />} />
|
||||||
<Route path="/offline" element={<OfflineLibrary />} />
|
<Route path="/offline" element={<OfflineLibrary />} />
|
||||||
|
<Route path="/genres" element={<Genres />} />
|
||||||
|
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+13
-2
@@ -230,8 +230,19 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||||
const data = await api<{ genres: { genre: SubsonicGenre[] } }>('getGenres.view');
|
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
|
||||||
return data.genres?.genre ?? [];
|
const raw = data.genres?.genre;
|
||||||
|
if (!raw) return [];
|
||||||
|
return Array.isArray(raw) ? raw : [raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
|
||||||
|
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||||
|
type: 'byGenre', genre, size, offset, _t: Date.now(),
|
||||||
|
});
|
||||||
|
const raw = data.albumList2?.album;
|
||||||
|
if (!raw) return [];
|
||||||
|
return Array.isArray(raw) ? raw : [raw];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResults {
|
export interface SearchResults {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Filter, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getGenres } from '../api/subsonic';
|
||||||
|
|
||||||
|
interface GenreFilterBarProps {
|
||||||
|
selected: string[];
|
||||||
|
onSelectionChange: (selected: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenreFilterBar({ selected, onSelectionChange }: GenreFilterBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [genres, setGenres] = useState<string[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGenres().then(data =>
|
||||||
|
setGenres(data.map(g => g.value).sort((a, b) => a.localeCompare(b)))
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// sync open state with selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected.length > 0) setOpen(true);
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const filteredOptions = genres.filter(
|
||||||
|
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const add = (genre: string) => {
|
||||||
|
onSelectionChange([...selected, genre]);
|
||||||
|
setSearch('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (genre: string) => {
|
||||||
|
onSelectionChange(selected.filter(s => s !== genre));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
onSelectionChange([]);
|
||||||
|
setSearch('');
|
||||||
|
setOpen(false);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFilter = () => {
|
||||||
|
setOpen(true);
|
||||||
|
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<Filter size={14} />
|
||||||
|
{t('common.filterGenre')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
// relatedTarget is the next focused element; if it's outside our container, handle close
|
||||||
|
const next = e.relatedTarget as Node | null;
|
||||||
|
if (containerRef.current && next && containerRef.current.contains(next)) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (selected.length === 0) {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
setDropdownOpen(false);
|
||||||
|
} else {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
<div className="genre-filter-tagbox">
|
||||||
|
{selected.map(g => (
|
||||||
|
<span key={g} className="genre-filter-chip">
|
||||||
|
{g}
|
||||||
|
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="genre-filter-input"
|
||||||
|
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
|
||||||
|
onFocus={() => setDropdownOpen(true)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
|
||||||
|
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
|
||||||
|
remove(selected[selected.length - 1]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dropdownOpen && filteredOptions.length > 0 && (
|
||||||
|
<div className="genre-filter-dropdown">
|
||||||
|
{filteredOptions.slice(0, 60).map(g => (
|
||||||
|
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
|
||||||
|
{g}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
|
||||||
|
<div className="genre-filter-dropdown">
|
||||||
|
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
|
||||||
|
<X size={13} />
|
||||||
|
{t('common.filterClear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ export default function PsysonicLogo({ className, style }: Props) {
|
|||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||||
<linearGradient id="psysonicGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="psysonicGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stopColor="var(--accent)" />
|
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
|
||||||
<stop offset="100%" stopColor="var(--ctp-blue)" />
|
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs><g
|
</defs><g
|
||||||
id="layer1"
|
id="layer1"
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { version as appVersion } from '../../package.json';
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload
|
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import PsysonicLogo from './PsysonicLogo';
|
import PsysonicLogo from './PsysonicLogo';
|
||||||
import PSmallLogo from './PSmallLogo';
|
import PSmallLogo from './PSmallLogo';
|
||||||
@@ -19,7 +19,7 @@ const navItems = [
|
|||||||
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
||||||
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
|
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
|
||||||
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
|
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
|
||||||
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
|
{ icon: Tags, labelKey: 'sidebar.genres', to: '/genres' },
|
||||||
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
|
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
|
||||||
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -60,15 +60,18 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
|||||||
{
|
{
|
||||||
group: 'Operating Systems',
|
group: 'Operating Systems',
|
||||||
themes: [
|
themes: [
|
||||||
|
{ id: 'ubuntu-ambiance', label: 'Ubuntu', bg: '#f4efea', card: '#3d1f3d', accent: '#e95420' },
|
||||||
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
|
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
|
||||||
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
|
||||||
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
||||||
|
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
||||||
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
|
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
|
||||||
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
|
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
|
||||||
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
|
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
|
||||||
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
|
|
||||||
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
|
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
|
||||||
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
|
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
|
||||||
|
{ id: 'wista', label: 'Wista', bg: '#eef3fc', card: '#0e1e3e', accent: '#1565c8' },
|
||||||
|
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
|
||||||
|
{ id: 'w10', label: 'W10', bg: '#f3f3f3', card: '#ffffff', accent: '#0078d4' },
|
||||||
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
|
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+100
-90
@@ -10,7 +10,6 @@ const enTranslation = {
|
|||||||
allAlbums: 'All Albums',
|
allAlbums: 'All Albums',
|
||||||
randomAlbums: 'Random Albums',
|
randomAlbums: 'Random Albums',
|
||||||
artists: 'Artists',
|
artists: 'Artists',
|
||||||
playlists: 'Playlists',
|
|
||||||
randomMix: 'Random Mix',
|
randomMix: 'Random Mix',
|
||||||
favorites: 'Favorites',
|
favorites: 'Favorites',
|
||||||
nowPlaying: 'Now Playing',
|
nowPlaying: 'Now Playing',
|
||||||
@@ -25,6 +24,7 @@ const enTranslation = {
|
|||||||
updateLink: 'Go to release →',
|
updateLink: 'Go to release →',
|
||||||
downloadingTracks: 'Caching {{n}} tracks…',
|
downloadingTracks: 'Caching {{n}} tracks…',
|
||||||
offlineLibrary: 'Offline Library',
|
offlineLibrary: 'Offline Library',
|
||||||
|
genres: 'Genres',
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
starred: 'Personal Favorites',
|
starred: 'Personal Favorites',
|
||||||
@@ -161,6 +161,18 @@ const enTranslation = {
|
|||||||
title: 'Random Albums',
|
title: 'Random Albums',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
},
|
},
|
||||||
|
genres: {
|
||||||
|
title: 'Genres',
|
||||||
|
genreCount: 'Genres',
|
||||||
|
albumCount_one: '{{count}} album',
|
||||||
|
albumCount_other: '{{count}} albums',
|
||||||
|
loading: 'Loading genres…',
|
||||||
|
empty: 'No genres found.',
|
||||||
|
albumsLoading: 'Loading albums…',
|
||||||
|
albumsEmpty: 'No albums found for this genre.',
|
||||||
|
loadMore: 'Load more',
|
||||||
|
back: 'Back',
|
||||||
|
},
|
||||||
randomMix: {
|
randomMix: {
|
||||||
title: 'Random Mix',
|
title: 'Random Mix',
|
||||||
remix: 'Remix',
|
remix: 'Remix',
|
||||||
@@ -191,22 +203,6 @@ const enTranslation = {
|
|||||||
filterPanelTitle: 'Filters',
|
filterPanelTitle: 'Filters',
|
||||||
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
|
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
|
||||||
},
|
},
|
||||||
playlists: {
|
|
||||||
title: 'Playlists',
|
|
||||||
loading: 'Loading playlists…',
|
|
||||||
empty: 'No playlists found.\nUse the queue to create playlists.',
|
|
||||||
play: 'Play',
|
|
||||||
deleteTooltip: 'Delete',
|
|
||||||
confirmDelete: 'Really delete playlist "{{name}}"?',
|
|
||||||
minutes: 'min.',
|
|
||||||
track_one: '{{count}} Track',
|
|
||||||
track_other: '{{count}} Tracks',
|
|
||||||
filterPlaceholder: 'Filter playlists…',
|
|
||||||
colName: 'Name',
|
|
||||||
colTracks: 'Tracks',
|
|
||||||
colDuration: 'Duration',
|
|
||||||
noResults: 'No playlists match your filter.',
|
|
||||||
},
|
|
||||||
albums: {
|
albums: {
|
||||||
title: 'All Albums',
|
title: 'All Albums',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
@@ -282,6 +278,10 @@ const enTranslation = {
|
|||||||
chooseDownloadFolder: 'Choose download folder',
|
chooseDownloadFolder: 'Choose download folder',
|
||||||
noFolderSelected: 'No folder selected',
|
noFolderSelected: 'No folder selected',
|
||||||
rememberDownloadFolder: 'Remember this folder',
|
rememberDownloadFolder: 'Remember this folder',
|
||||||
|
filterGenre: 'Genre Filter',
|
||||||
|
filterSearchGenres: 'Search genres…',
|
||||||
|
filterNoGenres: 'No genres match',
|
||||||
|
filterClear: 'Clear',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -358,6 +358,8 @@ const enTranslation = {
|
|||||||
aboutVersion: 'Version',
|
aboutVersion: 'Version',
|
||||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||||
|
aboutContributorsLabel: 'Contributors',
|
||||||
|
aboutContributors: 'jiezhuo — Chinese translation',
|
||||||
changelog: 'Changelog',
|
changelog: 'Changelog',
|
||||||
randomMixTitle: 'Random Mix',
|
randomMixTitle: 'Random Mix',
|
||||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||||
@@ -434,7 +436,7 @@ const enTranslation = {
|
|||||||
q12: 'How do I change the theme?',
|
q12: 'How do I change the theme?',
|
||||||
a12: 'Settings → Theme. Choose from 47 themes across 7 groups: Psysonic Themes, Psysonic Mediaplayer, Operating Systems, Games, Movies, Series, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Settings → Theme. Choose from 47 themes across 7 groups: Psysonic Themes, Psysonic Mediaplayer, Operating Systems, Games, Movies, Series, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'How do I change the language?',
|
q13: 'How do I change the language?',
|
||||||
a13: 'Settings → Language. English, German, French, and Dutch are supported.',
|
a13: 'Settings → Language. English, German, French, Dutch, and Chinese are supported.',
|
||||||
q15: 'How do I set a download folder?',
|
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.',
|
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',
|
s5: 'Scrobbling',
|
||||||
@@ -578,7 +580,6 @@ const deTranslation = {
|
|||||||
allAlbums: 'Alle Alben',
|
allAlbums: 'Alle Alben',
|
||||||
randomAlbums: 'Zufallsalben',
|
randomAlbums: 'Zufallsalben',
|
||||||
artists: 'Künstler',
|
artists: 'Künstler',
|
||||||
playlists: 'Playlists',
|
|
||||||
randomMix: 'Zufallsmix',
|
randomMix: 'Zufallsmix',
|
||||||
favorites: 'Favoriten',
|
favorites: 'Favoriten',
|
||||||
nowPlaying: 'Now Playing',
|
nowPlaying: 'Now Playing',
|
||||||
@@ -593,6 +594,7 @@ const deTranslation = {
|
|||||||
updateLink: 'Zum Release →',
|
updateLink: 'Zum Release →',
|
||||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||||
offlineLibrary: 'Offline-Bibliothek',
|
offlineLibrary: 'Offline-Bibliothek',
|
||||||
|
genres: 'Genres',
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
starred: 'Persönliche Favoriten',
|
starred: 'Persönliche Favoriten',
|
||||||
@@ -729,6 +731,18 @@ const deTranslation = {
|
|||||||
title: 'Zufallsalben',
|
title: 'Zufallsalben',
|
||||||
refresh: 'Neu laden',
|
refresh: 'Neu laden',
|
||||||
},
|
},
|
||||||
|
genres: {
|
||||||
|
title: 'Genres',
|
||||||
|
genreCount: 'Genres',
|
||||||
|
albumCount_one: '{{count}} Album',
|
||||||
|
albumCount_other: '{{count}} Alben',
|
||||||
|
loading: 'Genres werden geladen…',
|
||||||
|
empty: 'Keine Genres gefunden.',
|
||||||
|
albumsLoading: 'Alben werden geladen…',
|
||||||
|
albumsEmpty: 'Keine Alben in diesem Genre gefunden.',
|
||||||
|
loadMore: 'Mehr laden',
|
||||||
|
back: 'Zurück',
|
||||||
|
},
|
||||||
randomMix: {
|
randomMix: {
|
||||||
title: 'Zufallsmix',
|
title: 'Zufallsmix',
|
||||||
remix: 'Neu mixen',
|
remix: 'Neu mixen',
|
||||||
@@ -759,22 +773,6 @@ const deTranslation = {
|
|||||||
filterPanelTitle: 'Filter',
|
filterPanelTitle: 'Filter',
|
||||||
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
|
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
|
||||||
},
|
},
|
||||||
playlists: {
|
|
||||||
title: 'Playlists',
|
|
||||||
loading: 'Lade Playlists…',
|
|
||||||
empty: 'Keine Playlists gefunden.\nNutze die Warteschlange, um Playlists zu erstellen.',
|
|
||||||
play: 'Abspielen',
|
|
||||||
deleteTooltip: 'Löschen',
|
|
||||||
confirmDelete: 'Playlist "{{name}}" wirklich löschen?',
|
|
||||||
minutes: 'Min.',
|
|
||||||
track_one: '{{count}} Track',
|
|
||||||
track_other: '{{count}} Tracks',
|
|
||||||
filterPlaceholder: 'Playlists filtern…',
|
|
||||||
colName: 'Name',
|
|
||||||
colTracks: 'Tracks',
|
|
||||||
colDuration: 'Dauer',
|
|
||||||
noResults: 'Keine Playlists entsprechen dem Filter.',
|
|
||||||
},
|
|
||||||
albums: {
|
albums: {
|
||||||
title: 'Alle Alben',
|
title: 'Alle Alben',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
@@ -850,6 +848,10 @@ const deTranslation = {
|
|||||||
chooseDownloadFolder: 'Download-Ordner wählen',
|
chooseDownloadFolder: 'Download-Ordner wählen',
|
||||||
noFolderSelected: 'Kein Ordner ausgewählt',
|
noFolderSelected: 'Kein Ordner ausgewählt',
|
||||||
rememberDownloadFolder: 'Ordner merken',
|
rememberDownloadFolder: 'Ordner merken',
|
||||||
|
filterGenre: 'Genre-Filter',
|
||||||
|
filterSearchGenres: 'Genres suchen…',
|
||||||
|
filterNoGenres: 'Keine Genres gefunden',
|
||||||
|
filterClear: 'Zurücksetzen',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Einstellungen',
|
title: 'Einstellungen',
|
||||||
@@ -926,6 +928,8 @@ const deTranslation = {
|
|||||||
aboutVersion: 'Version',
|
aboutVersion: 'Version',
|
||||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||||
|
aboutContributorsLabel: 'Mitwirkende',
|
||||||
|
aboutContributors: 'jiezhuo — Chinesische Übersetzung',
|
||||||
changelog: 'Changelog',
|
changelog: 'Changelog',
|
||||||
randomMixTitle: 'Zufallsmix',
|
randomMixTitle: 'Zufallsmix',
|
||||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||||
@@ -1002,7 +1006,7 @@ const deTranslation = {
|
|||||||
q12: 'Wie ändere ich das Theme?',
|
q12: 'Wie ändere ich das Theme?',
|
||||||
a12: 'Einstellungen → Design. 47 Designs in 7 Gruppen: Psysonic Themes, Psysonic Mediaplayer, Betriebssysteme, Spiele, Filme, Serien und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Einstellungen → Design. 47 Designs in 7 Gruppen: Psysonic Themes, Psysonic Mediaplayer, Betriebssysteme, Spiele, Filme, Serien und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'Wie ändere ich die Sprache?',
|
q13: 'Wie ändere ich die Sprache?',
|
||||||
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch und Niederländisch werden unterstützt.',
|
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch, Niederländisch und Chinesisch werden unterstützt.',
|
||||||
q15: 'Wie lege ich einen Download-Ordner fest?',
|
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.',
|
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',
|
s5: 'Scrobbling',
|
||||||
@@ -1146,7 +1150,6 @@ const frTranslation = {
|
|||||||
allAlbums: 'Tous les albums',
|
allAlbums: 'Tous les albums',
|
||||||
randomAlbums: 'Albums aléatoires',
|
randomAlbums: 'Albums aléatoires',
|
||||||
artists: 'Artistes',
|
artists: 'Artistes',
|
||||||
playlists: 'Listes de lecture',
|
|
||||||
randomMix: 'Mix aléatoire',
|
randomMix: 'Mix aléatoire',
|
||||||
favorites: 'Favoris',
|
favorites: 'Favoris',
|
||||||
nowPlaying: 'En cours',
|
nowPlaying: 'En cours',
|
||||||
@@ -1161,6 +1164,7 @@ const frTranslation = {
|
|||||||
updateLink: 'Voir la version →',
|
updateLink: 'Voir la version →',
|
||||||
downloadingTracks: '{{n}} pistes en cache…',
|
downloadingTracks: '{{n}} pistes en cache…',
|
||||||
offlineLibrary: 'Bibliothèque hors ligne',
|
offlineLibrary: 'Bibliothèque hors ligne',
|
||||||
|
genres: 'Genres',
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
starred: 'Favoris personnels',
|
starred: 'Favoris personnels',
|
||||||
@@ -1297,6 +1301,18 @@ const frTranslation = {
|
|||||||
title: 'Albums aléatoires',
|
title: 'Albums aléatoires',
|
||||||
refresh: 'Actualiser',
|
refresh: 'Actualiser',
|
||||||
},
|
},
|
||||||
|
genres: {
|
||||||
|
title: 'Genres',
|
||||||
|
genreCount: 'genres',
|
||||||
|
albumCount_one: '{{count}} album',
|
||||||
|
albumCount_other: '{{count}} albums',
|
||||||
|
loading: 'Chargement des genres…',
|
||||||
|
empty: 'Aucun genre trouvé.',
|
||||||
|
albumsLoading: 'Chargement des albums…',
|
||||||
|
albumsEmpty: 'Aucun album trouvé pour ce genre.',
|
||||||
|
loadMore: 'Charger plus',
|
||||||
|
back: 'Retour',
|
||||||
|
},
|
||||||
randomMix: {
|
randomMix: {
|
||||||
title: 'Mix aléatoire',
|
title: 'Mix aléatoire',
|
||||||
remix: 'Remixer',
|
remix: 'Remixer',
|
||||||
@@ -1327,22 +1343,6 @@ const frTranslation = {
|
|||||||
filterPanelTitle: 'Filtres',
|
filterPanelTitle: 'Filtres',
|
||||||
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
|
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
|
||||||
},
|
},
|
||||||
playlists: {
|
|
||||||
title: 'Listes de lecture',
|
|
||||||
loading: 'Chargement des listes…',
|
|
||||||
empty: 'Aucune liste de lecture.\nUtilisez la file d\'attente pour en créer.',
|
|
||||||
play: 'Lire',
|
|
||||||
deleteTooltip: 'Supprimer',
|
|
||||||
confirmDelete: 'Supprimer la liste « {{name}} » ?',
|
|
||||||
minutes: 'min.',
|
|
||||||
track_one: '{{count}} piste',
|
|
||||||
track_other: '{{count}} pistes',
|
|
||||||
filterPlaceholder: 'Filtrer les listes…',
|
|
||||||
colName: 'Nom',
|
|
||||||
colTracks: 'Pistes',
|
|
||||||
colDuration: 'Durée',
|
|
||||||
noResults: 'Aucune liste ne correspond au filtre.',
|
|
||||||
},
|
|
||||||
albums: {
|
albums: {
|
||||||
title: 'Tous les albums',
|
title: 'Tous les albums',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
@@ -1418,6 +1418,10 @@ const frTranslation = {
|
|||||||
chooseDownloadFolder: 'Choisir le dossier de téléchargement',
|
chooseDownloadFolder: 'Choisir le dossier de téléchargement',
|
||||||
noFolderSelected: 'Aucun dossier sélectionné',
|
noFolderSelected: 'Aucun dossier sélectionné',
|
||||||
rememberDownloadFolder: 'Mémoriser ce dossier',
|
rememberDownloadFolder: 'Mémoriser ce dossier',
|
||||||
|
filterGenre: 'Filtre genre',
|
||||||
|
filterSearchGenres: 'Rechercher des genres…',
|
||||||
|
filterNoGenres: 'Aucun genre trouvé',
|
||||||
|
filterClear: 'Effacer',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Paramètres',
|
title: 'Paramètres',
|
||||||
@@ -1494,6 +1498,8 @@ const frTranslation = {
|
|||||||
aboutVersion: 'Version',
|
aboutVersion: 'Version',
|
||||||
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
|
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
|
||||||
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
|
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
|
||||||
|
aboutContributorsLabel: 'Contributeurs',
|
||||||
|
aboutContributors: 'jiezhuo — traduction chinoise',
|
||||||
changelog: 'Journal des modifications',
|
changelog: 'Journal des modifications',
|
||||||
randomMixTitle: 'Mix aléatoire',
|
randomMixTitle: 'Mix aléatoire',
|
||||||
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
|
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
|
||||||
@@ -1570,7 +1576,7 @@ const frTranslation = {
|
|||||||
q12: 'Comment changer le thème ?',
|
q12: 'Comment changer le thème ?',
|
||||||
a12: 'Paramètres → Thème. 47 thèmes en 7 groupes : Psysonic Themes, Psysonic Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries et Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Paramètres → Thème. 47 thèmes en 7 groupes : Psysonic Themes, Psysonic Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries et Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'Comment changer la langue ?',
|
q13: 'Comment changer la langue ?',
|
||||||
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français et le néerlandais sont pris en charge.',
|
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français, le néerlandais et le chinois sont pris en charge.',
|
||||||
q15: 'Comment définir un dossier de téléchargement ?',
|
q15: 'Comment définir un dossier de téléchargement ?',
|
||||||
a15: 'Paramètres → Comportement → Dossier de téléchargement. Les albums téléchargés y sont enregistrés en ZIP.',
|
a15: 'Paramètres → Comportement → Dossier de téléchargement. Les albums téléchargés y sont enregistrés en ZIP.',
|
||||||
s5: 'Scrobbling',
|
s5: 'Scrobbling',
|
||||||
@@ -1714,7 +1720,6 @@ const nlTranslation = {
|
|||||||
allAlbums: 'Alle albums',
|
allAlbums: 'Alle albums',
|
||||||
randomAlbums: 'Willekeurige albums',
|
randomAlbums: 'Willekeurige albums',
|
||||||
artists: 'Artiesten',
|
artists: 'Artiesten',
|
||||||
playlists: 'Afspeellijsten',
|
|
||||||
randomMix: 'Willekeurige mix',
|
randomMix: 'Willekeurige mix',
|
||||||
favorites: 'Favorieten',
|
favorites: 'Favorieten',
|
||||||
nowPlaying: 'Nu bezig',
|
nowPlaying: 'Nu bezig',
|
||||||
@@ -1729,6 +1734,7 @@ const nlTranslation = {
|
|||||||
updateLink: 'Naar release →',
|
updateLink: 'Naar release →',
|
||||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||||
offlineLibrary: 'Offline bibliotheek',
|
offlineLibrary: 'Offline bibliotheek',
|
||||||
|
genres: 'Genres',
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
starred: 'Persoonlijke favorieten',
|
starred: 'Persoonlijke favorieten',
|
||||||
@@ -1865,6 +1871,18 @@ const nlTranslation = {
|
|||||||
title: 'Willekeurige albums',
|
title: 'Willekeurige albums',
|
||||||
refresh: 'Vernieuwen',
|
refresh: 'Vernieuwen',
|
||||||
},
|
},
|
||||||
|
genres: {
|
||||||
|
title: 'Genres',
|
||||||
|
genreCount: 'genres',
|
||||||
|
albumCount_one: '{{count}} album',
|
||||||
|
albumCount_other: '{{count}} albums',
|
||||||
|
loading: 'Genres laden…',
|
||||||
|
empty: 'Geen genres gevonden.',
|
||||||
|
albumsLoading: 'Albums laden…',
|
||||||
|
albumsEmpty: 'Geen albums gevonden voor dit genre.',
|
||||||
|
loadMore: 'Meer laden',
|
||||||
|
back: 'Terug',
|
||||||
|
},
|
||||||
randomMix: {
|
randomMix: {
|
||||||
title: 'Willekeurige mix',
|
title: 'Willekeurige mix',
|
||||||
remix: 'Opnieuw mixen',
|
remix: 'Opnieuw mixen',
|
||||||
@@ -1895,22 +1913,6 @@ const nlTranslation = {
|
|||||||
filterPanelTitle: 'Filters',
|
filterPanelTitle: 'Filters',
|
||||||
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
|
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
|
||||||
},
|
},
|
||||||
playlists: {
|
|
||||||
title: 'Afspeellijsten',
|
|
||||||
loading: 'Afspeellijsten laden…',
|
|
||||||
empty: 'Geen afspeellijsten gevonden.\nGebruik de wachtrij om afspeellijsten aan te maken.',
|
|
||||||
play: 'Afspelen',
|
|
||||||
deleteTooltip: 'Verwijderen',
|
|
||||||
confirmDelete: 'Afspeellijst "{{name}}" echt verwijderen?',
|
|
||||||
minutes: 'min.',
|
|
||||||
track_one: '{{count}} nummer',
|
|
||||||
track_other: '{{count}} nummers',
|
|
||||||
filterPlaceholder: 'Afspeellijsten filteren…',
|
|
||||||
colName: 'Naam',
|
|
||||||
colTracks: 'Nummers',
|
|
||||||
colDuration: 'Duur',
|
|
||||||
noResults: 'Geen afspeellijsten komen overeen met het filter.',
|
|
||||||
},
|
|
||||||
albums: {
|
albums: {
|
||||||
title: 'Alle albums',
|
title: 'Alle albums',
|
||||||
sortByName: 'A–Z (Album)',
|
sortByName: 'A–Z (Album)',
|
||||||
@@ -1986,6 +1988,10 @@ const nlTranslation = {
|
|||||||
chooseDownloadFolder: 'Downloadmap kiezen',
|
chooseDownloadFolder: 'Downloadmap kiezen',
|
||||||
noFolderSelected: 'Geen map geselecteerd',
|
noFolderSelected: 'Geen map geselecteerd',
|
||||||
rememberDownloadFolder: 'Deze map onthouden',
|
rememberDownloadFolder: 'Deze map onthouden',
|
||||||
|
filterGenre: 'Genre-filter',
|
||||||
|
filterSearchGenres: 'Genres zoeken…',
|
||||||
|
filterNoGenres: 'Geen genres gevonden',
|
||||||
|
filterClear: 'Wissen',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Instellingen',
|
title: 'Instellingen',
|
||||||
@@ -2062,6 +2068,8 @@ const nlTranslation = {
|
|||||||
aboutVersion: 'Versie',
|
aboutVersion: 'Versie',
|
||||||
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
|
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
|
||||||
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
|
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
|
||||||
|
aboutContributorsLabel: 'Bijdragers',
|
||||||
|
aboutContributors: 'jiezhuo — Chinese vertaling',
|
||||||
changelog: 'Wijzigingslog',
|
changelog: 'Wijzigingslog',
|
||||||
randomMixTitle: 'Willekeurige mix',
|
randomMixTitle: 'Willekeurige mix',
|
||||||
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
|
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
|
||||||
@@ -2138,7 +2146,7 @@ const nlTranslation = {
|
|||||||
q12: 'Hoe verander ik het thema?',
|
q12: 'Hoe verander ik het thema?',
|
||||||
a12: 'Instellingen → Thema. 47 thema\'s in 7 groepen: Psysonic Themes, Psysonic Mediaplayer, Besturingssystemen, Games, Films, Series en Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
a12: 'Instellingen → Thema. 47 thema\'s in 7 groepen: Psysonic Themes, Psysonic Mediaplayer, Besturingssystemen, Games, Films, Series en Open Source Classics (Catppuccin, Nord, Gruvbox).',
|
||||||
q13: 'Hoe verander ik de taal?',
|
q13: 'Hoe verander ik de taal?',
|
||||||
a13: 'Instellingen → Taal. Engels, Duits, Frans en Nederlands worden momenteel ondersteund.',
|
a13: 'Instellingen → Taal. Engels, Duits, Frans, Nederlands en Chinees worden momenteel ondersteund.',
|
||||||
q15: 'Hoe stel ik een downloadmap in?',
|
q15: 'Hoe stel ik een downloadmap in?',
|
||||||
a15: 'Instellingen → App-gedrag → Downloadmap. Gedownloade albums worden daar opgeslagen als ZIP-bestanden.',
|
a15: 'Instellingen → App-gedrag → Downloadmap. Gedownloade albums worden daar opgeslagen als ZIP-bestanden.',
|
||||||
s5: 'Scrobbling',
|
s5: 'Scrobbling',
|
||||||
@@ -2282,7 +2290,6 @@ const zhTranslation = {
|
|||||||
allAlbums: '全部专辑',
|
allAlbums: '全部专辑',
|
||||||
randomAlbums: '随机专辑',
|
randomAlbums: '随机专辑',
|
||||||
artists: '艺术家',
|
artists: '艺术家',
|
||||||
playlists: '播放列表',
|
|
||||||
randomMix: '随机混音',
|
randomMix: '随机混音',
|
||||||
favorites: '收藏夹',
|
favorites: '收藏夹',
|
||||||
nowPlaying: '正在播放',
|
nowPlaying: '正在播放',
|
||||||
@@ -2297,6 +2304,7 @@ const zhTranslation = {
|
|||||||
updateLink: '前往发布页面 →',
|
updateLink: '前往发布页面 →',
|
||||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||||
offlineLibrary: '离线音乐库',
|
offlineLibrary: '离线音乐库',
|
||||||
|
genres: '流派',
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
starred: '个人收藏',
|
starred: '个人收藏',
|
||||||
@@ -2433,6 +2441,18 @@ const zhTranslation = {
|
|||||||
title: '随机专辑',
|
title: '随机专辑',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
},
|
},
|
||||||
|
genres: {
|
||||||
|
title: '流派',
|
||||||
|
genreCount: '个流派',
|
||||||
|
albumCount_one: '{{count}} 张专辑',
|
||||||
|
albumCount_other: '{{count}} 张专辑',
|
||||||
|
loading: '正在加载流派…',
|
||||||
|
empty: '未找到流派。',
|
||||||
|
albumsLoading: '正在加载专辑…',
|
||||||
|
albumsEmpty: '未找到该流派的专辑。',
|
||||||
|
loadMore: '加载更多',
|
||||||
|
back: '返回',
|
||||||
|
},
|
||||||
randomMix: {
|
randomMix: {
|
||||||
title: '随机混音',
|
title: '随机混音',
|
||||||
remix: '重新混音',
|
remix: '重新混音',
|
||||||
@@ -2463,22 +2483,6 @@ const zhTranslation = {
|
|||||||
filterPanelTitle: '过滤器',
|
filterPanelTitle: '过滤器',
|
||||||
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
|
genreClickHint: '点击流派标签将其添加为过滤关键词。\\n匹配流派、标题、专辑和艺术家。',
|
||||||
},
|
},
|
||||||
playlists: {
|
|
||||||
title: '播放列表',
|
|
||||||
loading: '正在加载播放列表…',
|
|
||||||
empty: '未找到播放列表。\\n使用播放队列创建播放列表。',
|
|
||||||
play: '播放',
|
|
||||||
deleteTooltip: '删除',
|
|
||||||
confirmDelete: '确定要删除播放列表 "{{name}}" 吗?',
|
|
||||||
minutes: '分钟',
|
|
||||||
track_one: '{{count}} 首曲目',
|
|
||||||
track_other: '{{count}} 首曲目',
|
|
||||||
filterPlaceholder: '筛选播放列表…',
|
|
||||||
colName: '名称',
|
|
||||||
colTracks: '曲目',
|
|
||||||
colDuration: '时长',
|
|
||||||
noResults: '没有匹配的播放列表。',
|
|
||||||
},
|
|
||||||
albums: {
|
albums: {
|
||||||
title: '全部专辑',
|
title: '全部专辑',
|
||||||
sortByName: '按名称排序 (A-Z)',
|
sortByName: '按名称排序 (A-Z)',
|
||||||
@@ -2554,6 +2558,10 @@ const zhTranslation = {
|
|||||||
chooseDownloadFolder: '选择下载文件夹',
|
chooseDownloadFolder: '选择下载文件夹',
|
||||||
noFolderSelected: '未选择文件夹',
|
noFolderSelected: '未选择文件夹',
|
||||||
rememberDownloadFolder: '记住此文件夹',
|
rememberDownloadFolder: '记住此文件夹',
|
||||||
|
filterGenre: '流派筛选',
|
||||||
|
filterSearchGenres: '搜索流派…',
|
||||||
|
filterNoGenres: '未找到匹配流派',
|
||||||
|
filterClear: '清除',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '设置',
|
title: '设置',
|
||||||
@@ -2630,6 +2638,8 @@ const zhTranslation = {
|
|||||||
aboutVersion: '版本',
|
aboutVersion: '版本',
|
||||||
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
|
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
|
||||||
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
|
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
|
||||||
|
aboutContributorsLabel: '贡献者',
|
||||||
|
aboutContributors: 'jiezhuo — 中文翻译',
|
||||||
changelog: '更新日志',
|
changelog: '更新日志',
|
||||||
randomMixTitle: '随机混音',
|
randomMixTitle: '随机混音',
|
||||||
randomMixBlacklistTitle: '自定义过滤关键词',
|
randomMixBlacklistTitle: '自定义过滤关键词',
|
||||||
@@ -2706,7 +2716,7 @@ const zhTranslation = {
|
|||||||
q12: '如何更改主题?',
|
q12: '如何更改主题?',
|
||||||
a12: '设置 → 主题。从 7 个分组共 47 个主题中选择:Psysonic 主题、Psysonic 媒体播放器、操作系统、游戏、电影、电视剧,以及开源经典(Catppuccin、Nord、Gruvbox)。',
|
a12: '设置 → 主题。从 7 个分组共 47 个主题中选择:Psysonic 主题、Psysonic 媒体播放器、操作系统、游戏、电影、电视剧,以及开源经典(Catppuccin、Nord、Gruvbox)。',
|
||||||
q13: '如何更改语言?',
|
q13: '如何更改语言?',
|
||||||
a13: '设置 → 语言。支持英语、德语、法语和荷兰语。',
|
a13: '设置 → 语言。支持英语、德语、法语、荷兰语和中文。',
|
||||||
q15: '如何设置下载文件夹?',
|
q15: '如何设置下载文件夹?',
|
||||||
a15: '设置 → 应用行为 → 下载文件夹。选择任意文件夹 — 下载的专辑将以 ZIP 文件形式保存在那里。未设置自定义文件夹时,将使用浏览器的默认下载位置。',
|
a15: '设置 → 应用行为 → 下载文件夹。选择任意文件夹 — 下载的专辑将以 ZIP 文件形式保存在那里。未设置自定义文件夹时,将使用浏览器的默认下载位置。',
|
||||||
s5: '歌曲记录',
|
s5: '歌曲记录',
|
||||||
|
|||||||
@@ -91,32 +91,35 @@ export default function AlbumDetail() {
|
|||||||
|
|
||||||
const handlePlayAll = () => {
|
const handlePlayAll = () => {
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
|
const albumGenre = album.album.genre;
|
||||||
const tracks = album.songs.map(s => ({
|
const tracks = album.songs.map(s => ({
|
||||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||||
starred: s.starred, genre: s.genre,
|
starred: s.starred, genre: s.genre ?? albumGenre,
|
||||||
}));
|
}));
|
||||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnqueueAll = () => {
|
const handleEnqueueAll = () => {
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
|
const albumGenre = album.album.genre;
|
||||||
const tracks = album.songs.map(s => ({
|
const tracks = album.songs.map(s => ({
|
||||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||||
starred: s.starred, genre: s.genre,
|
starred: s.starred, genre: s.genre ?? albumGenre,
|
||||||
}));
|
}));
|
||||||
enqueue(tracks);
|
enqueue(tracks);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlaySong = (song: SubsonicSong) => {
|
const handlePlaySong = (song: SubsonicSong) => {
|
||||||
|
const albumGenre = album?.album.genre;
|
||||||
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, genre: song.genre,
|
starred: song.starred, genre: song.genre ?? albumGenre,
|
||||||
};
|
};
|
||||||
playTrack(track, [track]);
|
playTrack(track, [track]);
|
||||||
};
|
};
|
||||||
|
|||||||
+42
-18
@@ -1,10 +1,19 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
|
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||||
|
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
|
||||||
|
}
|
||||||
|
|
||||||
export default function Albums() {
|
export default function Albums() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -12,9 +21,9 @@ export default function Albums() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const PAGE_SIZE = 30;
|
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||||
|
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
|
const filtered = selectedGenres.length > 0;
|
||||||
|
|
||||||
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
|
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -28,27 +37,40 @@ export default function Albums() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { setPage(0); load(sort, 0); }, [sort, load]);
|
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchByGenres(genres);
|
||||||
|
const sorted = [...data].sort((a, b) =>
|
||||||
|
sortType === 'alphabeticalByArtist'
|
||||||
|
? a.artist.localeCompare(b.artist)
|
||||||
|
: a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
setAlbums(sorted);
|
||||||
|
setHasMore(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filtered) loadFiltered(selectedGenres, sort);
|
||||||
|
else { setPage(0); load(sort, 0); }
|
||||||
|
}, [sort, filtered, selectedGenres, load, loadFiltered]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore || filtered) return;
|
||||||
const next = page + 1;
|
const next = page + 1;
|
||||||
setPage(next);
|
setPage(next);
|
||||||
load(sort, next * PAGE_SIZE, true);
|
load(sort, next * PAGE_SIZE, true);
|
||||||
}, [loading, hasMore, page, sort, load]);
|
}, [loading, hasMore, page, sort, load, filtered]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
entries => {
|
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||||
if (entries[0].isIntersecting) {
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: '200px' }
|
{ rootMargin: '200px' }
|
||||||
);
|
);
|
||||||
if (observerTarget.current) {
|
if (observerTarget.current) observer.observe(observerTarget.current);
|
||||||
observer.observe(observerTarget.current);
|
|
||||||
}
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore]);
|
}, [loadMore]);
|
||||||
|
|
||||||
@@ -59,9 +81,9 @@ export default function Albums() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
<h1 className="page-title">{t('albums.title')}</h1>
|
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
{sortOptions.map(o => (
|
{sortOptions.map(o => (
|
||||||
<button
|
<button
|
||||||
key={o.value}
|
key={o.value}
|
||||||
@@ -72,6 +94,7 @@ export default function Albums() {
|
|||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,10 +107,11 @@ export default function Albums() {
|
|||||||
<div className="album-grid-wrap">
|
<div className="album-grid-wrap">
|
||||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||||
</div>
|
</div>
|
||||||
|
{!filtered && (
|
||||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowLeft, Disc3 } from 'lucide-react';
|
||||||
|
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||||
|
import AlbumCard from '../components/AlbumCard';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export default function GenreDetail() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const genre = decodeURIComponent(name ?? '');
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAlbums([]);
|
||||||
|
setOffset(0);
|
||||||
|
setHasMore(true);
|
||||||
|
setLoading(true);
|
||||||
|
getAlbumsByGenre(genre, PAGE_SIZE, 0)
|
||||||
|
.then(data => {
|
||||||
|
setAlbums(data);
|
||||||
|
setHasMore(data.length === PAGE_SIZE);
|
||||||
|
setOffset(PAGE_SIZE);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [genre]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (loadingMore || !hasMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
getAlbumsByGenre(genre, PAGE_SIZE, offset)
|
||||||
|
.then(data => {
|
||||||
|
setAlbums(prev => [...prev, ...data]);
|
||||||
|
setHasMore(data.length === PAGE_SIZE);
|
||||||
|
setOffset(prev => prev + PAGE_SIZE);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingMore(false));
|
||||||
|
}, [genre, offset, loadingMore, hasMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="content-body animate-fade-in">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span>{t('genres.back')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
|
||||||
|
{!loading && albums.length > 0 && (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||||
|
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
|
||||||
|
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
|
||||||
|
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<div className="album-grid-wrap">
|
||||||
|
{albums.map(album => <AlbumCard key={album.id} album={album} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMore && !loading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
|
||||||
|
<button className="btn btn-surface" onClick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore ? t('common.loadingMore') : t('genres.loadMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud,
|
||||||
|
Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star,
|
||||||
|
Tags, type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getGenres, SubsonicGenre } from '../api/subsonic';
|
||||||
|
|
||||||
|
function getGenreIcon(name: string): LucideIcon {
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
if (/ambient|drone|new age/.test(n)) return Cloud;
|
||||||
|
if (/metal|hardcore|thrash|death|grind|doom/.test(n)) return Zap;
|
||||||
|
if (/rock/.test(n)) return Radio;
|
||||||
|
if (/jazz/.test(n)) return Music2;
|
||||||
|
if (/classical|orchestra|chamber|baroque|opera|symphon/.test(n)) return Music;
|
||||||
|
if (/electronic|techno|edm|house|trance|electro|synth/.test(n)) return Cpu;
|
||||||
|
if (/hip.?hop|rap/.test(n)) return Mic;
|
||||||
|
if (/pop/.test(n)) return Star;
|
||||||
|
if (/folk|country|bluegrass|americana/.test(n)) return Leaf;
|
||||||
|
if (/blues/.test(n)) return Music2;
|
||||||
|
if (/soul|r.?b|funk|gospel/.test(n)) return Heart;
|
||||||
|
if (/reggae|ska|dub/.test(n)) return Sun;
|
||||||
|
if (/punk/.test(n)) return Flame;
|
||||||
|
if (/soundtrack|score|ost|film|movie|cinema/.test(n)) return Film;
|
||||||
|
if (/world|latin|afro|celtic|tribal|traditional/.test(n)) return Globe;
|
||||||
|
if (/audiobook|spoken|hörbuch|speech|comedy/.test(n)) return BookOpen;
|
||||||
|
if (/podcast/.test(n)) return Podcast;
|
||||||
|
return Headphones;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 genreColor(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];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCROLL_KEY = 'genres-scroll';
|
||||||
|
|
||||||
|
export default function Genres() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGenres()
|
||||||
|
.then(data => {
|
||||||
|
const sorted = [...data].sort((a, b) => b.albumCount - a.albumCount);
|
||||||
|
setGenres(sorted);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore scroll position after genres are rendered
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || genres.length === 0) return;
|
||||||
|
const saved = sessionStorage.getItem(SCROLL_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
const pos = parseInt(saved, 10);
|
||||||
|
sessionStorage.removeItem(SCROLL_KEY);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (containerRef.current) containerRef.current.scrollTop = pos;
|
||||||
|
});
|
||||||
|
}, [loading, genres.length]);
|
||||||
|
|
||||||
|
const handleGenreClick = (genreValue: string) => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop));
|
||||||
|
}
|
||||||
|
navigate(`/genres/${encodeURIComponent(genreValue)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="content-body animate-fade-in">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('genres.title')}</h1>
|
||||||
|
{!loading && genres.length > 0 && (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||||
|
<Tags size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
{genres.length} {t('genres.genreCount')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="loading-text">{t('genres.loading')}</p>}
|
||||||
|
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
|
||||||
|
|
||||||
|
{!loading && genres.length > 0 && (
|
||||||
|
<div className="album-grid-wrap">
|
||||||
|
{genres.map(genre => {
|
||||||
|
const Icon = getGenreIcon(genre.value);
|
||||||
|
const color = genreColor(genre.value);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={genre.value}
|
||||||
|
className="genre-card"
|
||||||
|
style={{ '--genre-color': color } as React.CSSProperties}
|
||||||
|
onClick={() => handleGenreClick(genre.value)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)}
|
||||||
|
data-tooltip={genre.value}
|
||||||
|
>
|
||||||
|
<div className="genre-card-watermark">
|
||||||
|
<Icon size={80} strokeWidth={1.2} />
|
||||||
|
</div>
|
||||||
|
<p className="genre-card-name">{genre.value}</p>
|
||||||
|
<p className="genre-card-count">
|
||||||
|
{t('genres.albumCount', { count: genre.albumCount })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+37
-16
@@ -1,17 +1,27 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
|
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||||
|
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
|
||||||
|
return union.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewReleases() {
|
export default function NewReleases() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const PAGE_SIZE = 30;
|
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||||
|
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
|
const filtered = selectedGenres.length > 0;
|
||||||
|
|
||||||
const load = useCallback(async (offset: number, append = false) => {
|
const load = useCallback(async (offset: number, append = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -25,33 +35,43 @@ export default function NewReleases() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { setPage(0); load(0); }, [load]);
|
const loadFiltered = useCallback(async (genres: string[]) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setAlbums(await fetchByGenres(genres));
|
||||||
|
setHasMore(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filtered) loadFiltered(selectedGenres);
|
||||||
|
else { setPage(0); load(0); }
|
||||||
|
}, [filtered, selectedGenres, load, loadFiltered]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore || filtered) return;
|
||||||
const next = page + 1;
|
const next = page + 1;
|
||||||
setPage(next);
|
setPage(next);
|
||||||
load(next * PAGE_SIZE, true);
|
load(next * PAGE_SIZE, true);
|
||||||
}, [loading, hasMore, page, load]);
|
}, [loading, hasMore, page, load, filtered]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
entries => {
|
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||||
if (entries[0].isIntersecting) {
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: '200px' }
|
{ rootMargin: '200px' }
|
||||||
);
|
);
|
||||||
if (observerTarget.current) {
|
if (observerTarget.current) observer.observe(observerTarget.current);
|
||||||
observer.observe(observerTarget.current);
|
|
||||||
}
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore]);
|
}, [loadMore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
|
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('sidebar.newReleases')}</h1>
|
||||||
|
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && albums.length === 0 ? (
|
{loading && albums.length === 0 ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||||
@@ -62,10 +82,11 @@ export default function NewReleases() {
|
|||||||
<div className="album-grid-wrap">
|
<div className="album-grid-wrap">
|
||||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||||
</div>
|
</div>
|
||||||
|
{!filtered && (
|
||||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
|
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
|
||||||
import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
type SortKey = 'name' | 'songCount' | 'duration';
|
|
||||||
type SortDir = 'asc' | 'desc';
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortHeader({
|
|
||||||
label, sortKey, current, dir, onSort
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
sortKey: SortKey;
|
|
||||||
current: SortKey;
|
|
||||||
dir: SortDir;
|
|
||||||
onSort: (k: SortKey) => void;
|
|
||||||
}) {
|
|
||||||
const active = current === sortKey;
|
|
||||||
return (
|
|
||||||
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
|
|
||||||
{label}
|
|
||||||
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Playlists() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
|
||||||
|
|
||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
|
||||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
|
||||||
|
|
||||||
const fetchPlaylists = () => {
|
|
||||||
setLoading(true);
|
|
||||||
getPlaylists()
|
|
||||||
.then(data => { setPlaylists(data); setLoading(false); })
|
|
||||||
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchPlaylists(); }, []);
|
|
||||||
|
|
||||||
const handleSort = (key: SortKey) => {
|
|
||||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
||||||
else { setSortKey(key); setSortDir('asc'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlay = async (id: string) => {
|
|
||||||
try {
|
|
||||||
const data = await getPlaylist(id);
|
|
||||||
const tracks = data.songs.map((s: any) => ({
|
|
||||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
|
||||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
|
|
||||||
coverArt: s.coverArt, track: s.track, year: s.year,
|
|
||||||
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
|
||||||
}));
|
|
||||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
|
||||||
} catch (e) { console.error('Failed to play playlist', e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
|
||||||
if (confirm(t('playlists.confirmDelete', { name }))) {
|
|
||||||
try { await deletePlaylist(id); fetchPlaylists(); }
|
|
||||||
catch (e) { console.error('Failed to delete playlist', e); }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
|
||||||
const q = filter.toLowerCase();
|
|
||||||
const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
|
|
||||||
return [...filtered].sort((a, b) => {
|
|
||||||
let cmp = 0;
|
|
||||||
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
|
|
||||||
else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
|
|
||||||
else cmp = a.duration - b.duration;
|
|
||||||
return sortDir === 'asc' ? cmp : -cmp;
|
|
||||||
});
|
|
||||||
}, [playlists, filter, sortKey, sortDir]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-body animate-fade-in">
|
|
||||||
<div className="playlist-page-header">
|
|
||||||
<h1 className="page-title">{t('playlists.title')}</h1>
|
|
||||||
<input
|
|
||||||
className="playlist-filter-input"
|
|
||||||
type="search"
|
|
||||||
placeholder={t('playlists.filterPlaceholder')}
|
|
||||||
value={filter}
|
|
||||||
onChange={e => setFilter(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading-center"><div className="spinner" /></div>
|
|
||||||
) : playlists.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
|
|
||||||
) : (
|
|
||||||
<div className="playlist-list">
|
|
||||||
<div className="playlist-list-header">
|
|
||||||
<div />
|
|
||||||
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
|
|
||||||
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
|
|
||||||
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
|
||||||
<div className="empty-state">{t('playlists.noResults')}</div>
|
|
||||||
) : visible.map(p => (
|
|
||||||
<div key={p.id} className="playlist-row">
|
|
||||||
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
|
|
||||||
<Play size={14} fill="currentColor" />
|
|
||||||
</button>
|
|
||||||
<span className="playlist-name truncate">{p.name}</span>
|
|
||||||
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
|
|
||||||
<span className="playlist-meta">{formatDuration(p.duration)}</span>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost playlist-delete-btn"
|
|
||||||
onClick={() => handleDelete(p.id, p.name)}
|
|
||||||
data-tooltip={t('playlists.deleteTooltip')}
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,40 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const ALBUM_COUNT = 30;
|
const ALBUM_COUNT = 30;
|
||||||
|
|
||||||
|
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||||
|
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
|
||||||
|
// Fisher-Yates shuffle
|
||||||
|
for (let i = union.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[union[i], union[j]] = [union[j], union[i]];
|
||||||
|
}
|
||||||
|
return union.slice(0, ALBUM_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RandomAlbums() {
|
export default function RandomAlbums() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
const filtered = selectedGenres.length > 0;
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async (genres: string[]) => {
|
||||||
if (loadingRef.current) return;
|
if (loadingRef.current) return;
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getAlbumList('random', ALBUM_COUNT);
|
const data = genres.length > 0
|
||||||
|
? await fetchByGenres(genres)
|
||||||
|
: await getAlbumList('random', ALBUM_COUNT);
|
||||||
setAlbums(data);
|
setAlbums(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -27,15 +44,17 @@ export default function RandomAlbums() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={load}
|
onClick={() => load(selectedGenres)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
data-tooltip={t('randomAlbums.refresh')}
|
data-tooltip={t('randomAlbums.refresh')}
|
||||||
>
|
>
|
||||||
@@ -43,6 +62,7 @@ export default function RandomAlbums() {
|
|||||||
{t('randomAlbums.refresh')}
|
{t('randomAlbums.refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && albums.length === 0 ? (
|
{loading && albums.length === 0 ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||||
|
|||||||
@@ -1017,6 +1017,10 @@ export default function Settings() {
|
|||||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
|
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
|
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutContributorsLabel')}</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutContributors')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11';
|
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10';
|
||||||
|
|
||||||
interface ThemeState {
|
interface ThemeState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
|||||||
@@ -4055,3 +4055,147 @@
|
|||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─ Genre Filter Bar ─ */
|
||||||
|
.genre-filter-tagbox {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
min-width: 220px;
|
||||||
|
cursor: text;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-tagbox:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.15rem 0.3rem 0.15rem 0.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-chip button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-chip button:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.genre-filter-input {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 100px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.genre-filter-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-option {
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-option:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-filter-empty {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─ Genre Cards ─ */
|
||||||
|
.genre-card {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background:
|
||||||
|
linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%),
|
||||||
|
var(--genre-color, var(--accent));
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-card:hover {
|
||||||
|
transform: translateY(-3px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-card-watermark {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: -10px;
|
||||||
|
transform: translateY(-55%);
|
||||||
|
opacity: 0.22;
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-card-name {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-card-count {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
}
|
||||||
|
|||||||
+925
-89
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,12 @@ function fadeOut(setVolume: (v: number) => void, from: number, durationMs: numbe
|
|||||||
|
|
||||||
export async function playAlbum(albumId: string): Promise<void> {
|
export async function playAlbum(albumId: string): Promise<void> {
|
||||||
const albumData = await getAlbum(albumId);
|
const albumData = await getAlbum(albumId);
|
||||||
const tracks = albumData.songs.map(songToTrack);
|
const albumGenre = albumData.album.genre;
|
||||||
|
const tracks = albumData.songs.map(s => {
|
||||||
|
const track = songToTrack(s);
|
||||||
|
if (!track.genre && albumGenre) track.genre = albumGenre;
|
||||||
|
return track;
|
||||||
|
});
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
|
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
|
|||||||
Reference in New Issue
Block a user