mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 901d260bf1 | |||
| ebef8aa061 | |||
| 9de56bf638 | |||
| 842e041a6e | |||
| 0de488335e | |||
| 2c95a3499c | |||
| de191e45d5 | |||
| 49ad3618a8 | |||
| 4148e063dd | |||
| 88f7a7bc90 | |||
| daa6fbbfd7 | |||
| aabd342a64 | |||
| fc34a0ec59 | |||
| f4e1086131 | |||
| 23f032b274 | |||
| aad1a6c3f0 | |||
| 10fc489ce9 | |||
| b3573e7107 | |||
| 9fb0d5638b | |||
| f9df918c72 | |||
| 03a1ba9582 | |||
| 2d3c723a6e | |||
| 40db6b08d2 | |||
| a66d932afe | |||
| f706336e58 | |||
| 1c23305887 | |||
| 41157ccaca | |||
| dc4eef1a97 |
@@ -21,6 +21,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Fullscreen player — rebuilt for much lower CPU/RAM
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
|
||||
|
||||
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
|
||||
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
|
||||
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
|
||||
|
||||
|
||||
|
||||
### Queue — Timeline display mode
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
|
||||
|
||||
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
|
||||
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
|
||||
|
||||
|
||||
|
||||
### Offline — unified local playback, library index join, and favorites sync
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
|
||||
|
||||
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
|
||||
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
|
||||
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
|
||||
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
|
||||
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
|
||||
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
|
||||
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
|
||||
|
||||
|
||||
|
||||
### Themes — community Theme Store
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020)**
|
||||
|
||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
|
||||
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
|
||||
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||
|
||||
|
||||
|
||||
### Offline — local-bytes browse when the server is down
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
|
||||
|
||||
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
|
||||
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
|
||||
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
|
||||
* PlayerBar hides star rating and favorite controls while offline browse is active.
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
@@ -41,6 +98,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Playback — Preload Next Track setting removed
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
|
||||
|
||||
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
|
||||
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
|
||||
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Servers — complete border on the active server card
|
||||
@@ -60,6 +126,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Track preview — Symphonia 0.6 format hints and fast stream start
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
|
||||
|
||||
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
|
||||
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
|
||||
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
|
||||
|
||||
|
||||
|
||||
### Mainstage — hero backdrop stays in sync when skipping albums quickly
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
|
||||
|
||||
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
|
||||
|
||||
|
||||
|
||||
## [1.47.0]
|
||||
|
||||
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
|
||||
|
||||
@@ -32,9 +32,6 @@ More translations are added over time.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
|
||||
|
||||
## What is Psysonic?
|
||||
|
||||
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
|
||||
|
||||
Generated
+1
@@ -4266,6 +4266,7 @@ dependencies = [
|
||||
"psysonic-analysis",
|
||||
"psysonic-audio",
|
||||
"psysonic-core",
|
||||
"psysonic-library",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -6,6 +6,7 @@ resolver = "2"
|
||||
version = "1.48.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
@@ -18,7 +19,7 @@ name = "psysonic"
|
||||
version.workspace = true
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
license.workspace = true
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
|
||||
@@ -21,6 +21,8 @@ accepted = [
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"GPL-3.0-or-later",
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
@@ -38,5 +40,4 @@ targets = [
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
filter-noassertion = false
|
||||
workarounds = ["ring"]
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
|
||||
#[derive(Default)]
|
||||
pub struct PlaybackPriorityHints {
|
||||
middle_track_ids: Mutex<HashSet<String>>,
|
||||
@@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file(
|
||||
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
|
||||
}
|
||||
|
||||
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
|
||||
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
|
||||
pub async fn enqueue_offline_library_analysis_from_file(
|
||||
app: &tauri::AppHandle,
|
||||
server_index_key: &str,
|
||||
library_server_id: &str,
|
||||
track_id: &str,
|
||||
file_path: &std::path::Path,
|
||||
explicit_priority: Option<AnalysisBackfillPriority>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::track_analysis_plan::plan_track_analysis_offline_library;
|
||||
|
||||
let mut file = tokio::fs::File::open(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut prefix = vec![0u8; 16384];
|
||||
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
|
||||
prefix.truncate(n);
|
||||
if prefix.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(&prefix);
|
||||
let plan = plan_track_analysis_offline_library(
|
||||
app,
|
||||
&[server_index_key, library_server_id],
|
||||
library_server_id,
|
||||
track_id,
|
||||
&content_hash,
|
||||
);
|
||||
if !plan.any() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
|
||||
track_id,
|
||||
server_index_key,
|
||||
library_server_id,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = tokio::fs::read(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let format_hint = file_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| !e.is_empty());
|
||||
let priority = explicit_priority.unwrap_or_else(|| {
|
||||
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
|
||||
});
|
||||
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
|
||||
app,
|
||||
cache_server_id: server_index_key,
|
||||
enrichment_server_id: library_server_id,
|
||||
track_id,
|
||||
bytes: &bytes,
|
||||
format_hint: format_hint.as_deref(),
|
||||
priority,
|
||||
plan,
|
||||
fetch_ms: 0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct OfflineLibraryAnalysisEnqueue<'a> {
|
||||
app: &'a tauri::AppHandle,
|
||||
cache_server_id: &'a str,
|
||||
enrichment_server_id: &'a str,
|
||||
track_id: &'a str,
|
||||
bytes: &'a [u8],
|
||||
format_hint: Option<&'a str>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
|
||||
fetch_ms: u64,
|
||||
}
|
||||
|
||||
async fn enqueue_track_analysis_offline_library_with_plan(
|
||||
args: OfflineLibraryAnalysisEnqueue<'_>,
|
||||
) -> Result<EnqueueTrackAnalysisOutcome, String> {
|
||||
if args.bytes.is_empty() || !args.plan.any() {
|
||||
return Ok(EnqueueTrackAnalysisOutcome::Complete);
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
|
||||
if args.plan.needs_full_cpu_seed() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
|
||||
args.track_id,
|
||||
content_hash,
|
||||
args.plan.need_waveform,
|
||||
args.plan.need_loudness,
|
||||
args.plan.enrichment.any()
|
||||
);
|
||||
submit_analysis_cpu_seed(
|
||||
args.app.clone(),
|
||||
args.cache_server_id.to_string(),
|
||||
args.track_id.to_string(),
|
||||
args.bytes.to_vec(),
|
||||
args.format_hint.map(str::to_string),
|
||||
args.priority,
|
||||
args.fetch_ms,
|
||||
)
|
||||
.await?;
|
||||
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
|
||||
}
|
||||
if args.plan.needs_enrichment_only() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] enrichment-only track_id={} hash={}",
|
||||
args.track_id,
|
||||
content_hash
|
||||
);
|
||||
let bpm_started = std::time::Instant::now();
|
||||
let outcome = run_track_enrichment_from_bytes(
|
||||
args.app,
|
||||
args.enrichment_server_id,
|
||||
args.track_id,
|
||||
args.bytes,
|
||||
analysis_emits_ui_events(args.priority),
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
|
||||
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
let key = analysis_cache::TrackKey {
|
||||
server_id: args.cache_server_id.to_string(),
|
||||
track_id: args.track_id.to_string(),
|
||||
md5_16kb: content_hash.clone(),
|
||||
};
|
||||
let _ = cache.touch_track_status(&key, "failed");
|
||||
}
|
||||
return Err("track enrichment failed".to_string());
|
||||
}
|
||||
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
|
||||
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
|
||||
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
|
||||
}
|
||||
Ok(EnqueueTrackAnalysisOutcome::Complete)
|
||||
}
|
||||
|
||||
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
|
||||
pub async fn enqueue_analysis_seed(
|
||||
app: &tauri::AppHandle,
|
||||
|
||||
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
|
||||
}
|
||||
|
||||
/// Offline/library download: waveform cache and enrichment facts may live under the
|
||||
/// playback index key while library rows use the UUID — try every scope before seeding.
|
||||
pub fn plan_track_analysis_offline_library(
|
||||
app: &AppHandle,
|
||||
cache_server_ids: &[&str],
|
||||
_enrichment_server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) =
|
||||
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
|
||||
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
|
||||
TrackAnalysisPlan {
|
||||
need_waveform,
|
||||
need_loudness,
|
||||
@@ -103,6 +116,32 @@ fn cache_gaps(
|
||||
)
|
||||
}
|
||||
|
||||
fn cache_gaps_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
let mut need_waveform = true;
|
||||
let mut need_loudness = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
if !nw {
|
||||
need_waveform = false;
|
||||
}
|
||||
if !nl {
|
||||
need_loudness = false;
|
||||
}
|
||||
if !need_waveform && !need_loudness {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(need_waveform, need_loudness)
|
||||
}
|
||||
|
||||
fn enrichment_plan(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
@@ -117,6 +156,45 @@ fn enrichment_plan(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enrichment_plan_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
let mut need_bpm = true;
|
||||
let mut need_valence = true;
|
||||
let mut need_arousal = true;
|
||||
let mut need_moods = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let plan = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
if !plan.need_bpm {
|
||||
need_bpm = false;
|
||||
}
|
||||
if !plan.need_valence {
|
||||
need_valence = false;
|
||||
}
|
||||
if !plan.need_arousal {
|
||||
need_arousal = false;
|
||||
}
|
||||
if !plan.need_moods {
|
||||
need_moods = false;
|
||||
}
|
||||
if !need_bpm && !need_valence && !need_arousal && !need_moods {
|
||||
break;
|
||||
}
|
||||
}
|
||||
psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
need_bpm,
|
||||
need_valence,
|
||||
need_arousal,
|
||||
need_moods,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_gaps_for_content(
|
||||
cache: Option<&AnalysisCache>,
|
||||
server_id: &str,
|
||||
@@ -194,4 +272,14 @@ mod tests {
|
||||
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
|
||||
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
|
||||
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
|
||||
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -166,15 +166,24 @@ impl SizedDecoder {
|
||||
inner: Cursor::new(data),
|
||||
len: data_len,
|
||||
};
|
||||
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
|
||||
// seekability during probe (same as `new_streaming`) so preview does not
|
||||
// read the entire in-memory file before the first sample.
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate {
|
||||
inner: Box::new(source),
|
||||
seekable: gate.clone(),
|
||||
}),
|
||||
None => Box::new(source),
|
||||
};
|
||||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||||
// and compete with the playback thread at track start.
|
||||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||||
let mss = MediaSourceStream::new(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||||
);
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint {
|
||||
@@ -200,6 +209,10 @@ impl SizedDecoder {
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(gate) = &probe_seek_gate {
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Short preview playback on a secondary sink (same output stream).
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
@@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::MASTER_HEADROOM;
|
||||
use super::helpers::{
|
||||
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
||||
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
MASTER_HEADROOM,
|
||||
};
|
||||
use super::play_input::url_format_hint;
|
||||
use super::sources::PriorityBoostSource;
|
||||
use super::stream::{
|
||||
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
|
||||
RangedHttpSource, RangedMp4ProbeGate,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||
@@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
|
||||
/// a `format=flac` query param for `.opus` files (server transcodes); for
|
||||
/// everything else we guess from the URL's `format=` value or fall back to None.
|
||||
/// `format=` query param on Subsonic stream URLs (transcode targets).
|
||||
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
||||
url.split('?')
|
||||
.nth(1)?
|
||||
.split('&')
|
||||
.find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
|
||||
if k.eq_ignore_ascii_case("format") {
|
||||
Some(v.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Symphonia container hint for preview downloads — mirrors main playback:
|
||||
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
|
||||
pub(crate) fn resolve_preview_format_hint(
|
||||
url: &str,
|
||||
content_type: Option<&str>,
|
||||
content_disposition: Option<&str>,
|
||||
stream_suffix: Option<&str>,
|
||||
bytes: &[u8],
|
||||
) -> Option<String> {
|
||||
let media_hint = content_type
|
||||
.and_then(content_type_to_hint)
|
||||
.or_else(|| {
|
||||
content_disposition.and_then(format_hint_from_content_disposition)
|
||||
});
|
||||
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
|
||||
resolve_playback_format_hint(
|
||||
url_hint.as_deref(),
|
||||
stream_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(state))
|
||||
}
|
||||
|
||||
/// Open a preview decoder — ranged HTTP when the server supports it (starts
|
||||
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
|
||||
async fn open_preview_decoder(
|
||||
url: &str,
|
||||
format_suffix: Option<&str>,
|
||||
gen: u64,
|
||||
state: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<SizedDecoder>, String> {
|
||||
let preview_http = preview_http_client(state);
|
||||
let response = preview_http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?;
|
||||
|
||||
let mut stream_hint = content_type_to_hint(
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(format_hint_from_content_disposition)
|
||||
})
|
||||
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
|
||||
.or_else(|| preview_format_hint_from_url(url))
|
||||
.or_else(|| url_format_hint(url));
|
||||
|
||||
let supports_range = response
|
||||
.headers()
|
||||
.get(reqwest::header::ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||
let total_size = response.content_length();
|
||||
|
||||
if stream_hint.is_none() && supports_range {
|
||||
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = preview_http
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let stat = pr.status();
|
||||
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| stat == reqwest::StatusCode::OK;
|
||||
if ok {
|
||||
if let Ok(bytes) = pr.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let total_usize = total as usize;
|
||||
crate::app_deprintln!(
|
||||
"[preview] ranged open — total={} KB, hint={:?}",
|
||||
total_usize / 1024,
|
||||
stream_hint
|
||||
);
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let playback_armed = Arc::new(AtomicBool::new(false));
|
||||
let tail_ready = Arc::new(AtomicBool::new(false));
|
||||
let tail_filled_from = Arc::new(AtomicU64::new(0));
|
||||
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
|
||||
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
|
||||
tail_ready: tail_ready.clone(),
|
||||
buf: buf.clone(),
|
||||
downloaded_to: downloaded_to.clone(),
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
format_hint: stream_hint.clone(),
|
||||
});
|
||||
tokio::spawn(ranged_download_task(
|
||||
gen,
|
||||
state.preview_gen.clone(),
|
||||
preview_http,
|
||||
app.clone(),
|
||||
0.0,
|
||||
url.to_string(),
|
||||
response,
|
||||
buf.clone(),
|
||||
downloaded_to.clone(),
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.stream_completed_spill.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
if let Some(ref gate) = mp4_probe_gate {
|
||||
wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready,
|
||||
tail_filled_from,
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
};
|
||||
let hint = stream_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
return Ok(Some(decoder));
|
||||
}
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||
supports_range,
|
||||
total_size,
|
||||
stream_hint
|
||||
);
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let content_disposition = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let hint = resolve_preview_format_hint(
|
||||
url,
|
||||
content_type.as_deref(),
|
||||
content_disposition.as_deref(),
|
||||
format_suffix,
|
||||
&bytes,
|
||||
);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
Ok(Some(decoder))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
|
||||
pub async fn audio_preview_play(
|
||||
id: String,
|
||||
url: String,
|
||||
start_sec: f64,
|
||||
duration_sec: f64,
|
||||
volume: f32,
|
||||
format_suffix: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -134,48 +363,24 @@ pub async fn audio_preview_play(
|
||||
preview_pause_main(&state);
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
// Dedicated client with a generous timeout. The shared `audio_http_client`
|
||||
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
|
||||
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
|
||||
// ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still
|
||||
// bounds how long the preview plays once the bytes are in memory, so a
|
||||
// long download just means a longer "loading" spinner before audio starts.
|
||||
let preview_http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(&state));
|
||||
let bytes = preview_http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
// ── Open decoder (ranged stream when possible) ───────────────────────────
|
||||
let decoder = match open_preview_decoder(
|
||||
&url,
|
||||
format_suffix.as_deref(),
|
||||
gen,
|
||||
&state,
|
||||
&app,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
// A newer preview started while we were downloading — bail.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ───────────────────────────────────────────────────────────────
|
||||
let hint = preview_format_hint_from_url(&url);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// ── Build source pipeline ────────────────────────────────────────────────
|
||||
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
||||
// before the seek made take_duration's wall-clock counter tick from
|
||||
@@ -271,6 +476,55 @@ pub async fn audio_preview_play(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
Some("audio/mpeg"),
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("mp3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_uses_subsonic_suffix() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
Some("flac"),
|
||||
&[0x00, 0x01, 0x02, 0x03],
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_format_hint_from_url_reads_format_query_param() {
|
||||
assert_eq!(
|
||||
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
|
||||
Some("opus".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, true);
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
|| id.starts_with("ra-")
|
||||
}
|
||||
|
||||
/// Windows reserved device names (case-insensitive) — invalid as path components.
|
||||
const WINDOWS_RESERVED_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
|
||||
/// Also used for media layout artist/album/title segments from server metadata.
|
||||
pub fn sanitize_path_segment(segment: &str) -> String {
|
||||
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let trimmed = segment.trim();
|
||||
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
if trimmed.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
trimmed
|
||||
let cleaned: String = trimmed
|
||||
.chars()
|
||||
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
|
||||
.collect()
|
||||
.map(|c| {
|
||||
if c.is_control() || FORBIDDEN.contains(&c) {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
return "_".to_string();
|
||||
}
|
||||
let upper = cleaned.to_ascii_uppercase();
|
||||
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
|
||||
return format!("_{cleaned}");
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||
@@ -256,6 +277,13 @@ mod tests {
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_dot_dot_and_reserved_names() {
|
||||
assert_eq!(sanitize_path_segment(".."), "_");
|
||||
assert_eq!(sanitize_path_segment("CON"), "_CON");
|
||||
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_disk_usage_counts_canonical_only() {
|
||||
let server = test_server_dir("usage");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod cover_cache_layout;
|
||||
pub mod media_layout;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
pub mod track_analysis;
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Local playback disk layout — artist/album/track paths from library-index fields.
|
||||
//!
|
||||
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
|
||||
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
|
||||
//! index key; artist/album/filename segments are derived from track metadata only.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::cover_cache_layout::sanitize_path_segment;
|
||||
|
||||
/// Max length for a single path component after sanitization (Windows budget).
|
||||
pub const MAX_SEGMENT_LEN: usize = 120;
|
||||
|
||||
/// Inputs required to build hierarchical media paths (library index row projection).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TrackPathInput {
|
||||
pub artist: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub album: String,
|
||||
pub title: String,
|
||||
pub track_number: Option<i64>,
|
||||
pub disc_number: Option<i64>,
|
||||
pub suffix: Option<String>,
|
||||
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
|
||||
pub raw_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalTier {
|
||||
Ephemeral,
|
||||
Library,
|
||||
/// Auto-synced starred favorites — separate from user-pinned `library/`.
|
||||
Favorites,
|
||||
}
|
||||
|
||||
impl LocalTier {
|
||||
pub fn subdir(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ephemeral => "cache",
|
||||
Self::Library => "library",
|
||||
Self::Favorites => "favorites",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"ephemeral" | "cache" => Some(Self::Ephemeral),
|
||||
"library" => Some(Self::Library),
|
||||
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
|
||||
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
|
||||
let artist_seg = artist_folder_segment(input);
|
||||
let album_seg = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let suffix = input
|
||||
.suffix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("");
|
||||
let track_n = input.track_number.unwrap_or(0);
|
||||
let disc_n = input.disc_number.unwrap_or(0);
|
||||
format!(
|
||||
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
|
||||
input
|
||||
.album_artist
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or(""),
|
||||
input.title.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
|
||||
pub fn relative_path_for_track(
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
let server_segment = sanitize_path_segment(server_index_key);
|
||||
let artist = artist_folder_segment(input);
|
||||
let album = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let ext = suffix.trim().trim_start_matches('.');
|
||||
let filename = if ext.is_empty() {
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
|
||||
} else {
|
||||
format!(
|
||||
"{}.{}",
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
|
||||
sanitize_path_segment(ext)
|
||||
)
|
||||
};
|
||||
PathBuf::from(server_segment)
|
||||
.join(artist)
|
||||
.join(album)
|
||||
.join(filename)
|
||||
}
|
||||
|
||||
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
|
||||
pub fn absolute_track_path(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
media_root
|
||||
.join(tier.subdir())
|
||||
.join(relative_path_for_track(server_index_key, input, suffix))
|
||||
}
|
||||
|
||||
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
|
||||
pub fn ensure_track_path_within_tier(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
absolute: &Path,
|
||||
) -> Result<(), String> {
|
||||
let tier_root = media_root.join(tier.subdir());
|
||||
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
|
||||
return Err(format!(
|
||||
"path `{}` escapes tier root `{}`",
|
||||
absolute.display(),
|
||||
tier_root.display()
|
||||
));
|
||||
};
|
||||
for comp in rel.components() {
|
||||
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
|
||||
return Err(format!(
|
||||
"path `{}` contains forbidden component `{comp:?}`",
|
||||
absolute.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artist_folder_segment(input: &TrackPathInput) -> String {
|
||||
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let chosen = if artist.is_empty() || track_is_compilation(input) {
|
||||
if !album_artist.is_empty() {
|
||||
album_artist
|
||||
} else {
|
||||
"Various Artists"
|
||||
}
|
||||
} else {
|
||||
artist
|
||||
};
|
||||
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn album_folder_segment(album: &str) -> String {
|
||||
let trimmed = album.trim();
|
||||
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
|
||||
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn track_filename_stem(input: &TrackPathInput) -> String {
|
||||
let title = input.title.trim();
|
||||
let title = if title.is_empty() { "Unknown Title" } else { title };
|
||||
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
|
||||
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
|
||||
if disc_n > 1 {
|
||||
format!("{disc_n:02}-{track_n:02} - {title}")
|
||||
} else {
|
||||
format!("{track_n:02} - {title}")
|
||||
}
|
||||
}
|
||||
|
||||
fn track_is_compilation(input: &TrackPathInput) -> bool {
|
||||
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
|
||||
return true;
|
||||
}
|
||||
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
raw_json_marks_compilation(raw)
|
||||
}
|
||||
|
||||
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
|
||||
fn raw_json_marks_compilation(raw: &str) -> bool {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
lower.contains("\"iscompilation\":true")
|
||||
|| lower.contains("\"iscompilation\": true")
|
||||
|| lower.contains("\"compilation\":true")
|
||||
|| lower.contains("\"compilation\": true")
|
||||
|| lower.contains("\"compilation\":1")
|
||||
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
|
||||
}
|
||||
|
||||
fn various_artists_label(s: &str) -> bool {
|
||||
let lower = s.trim().to_ascii_lowercase();
|
||||
lower.contains("various artists")
|
||||
}
|
||||
|
||||
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
|
||||
let sanitized = sanitize_path_segment(segment);
|
||||
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
|
||||
if sanitized.chars().count() <= max_len {
|
||||
return sanitized;
|
||||
}
|
||||
let hash = short_hash(segment);
|
||||
let keep = max_len.saturating_sub(1 + hash.len());
|
||||
let mut out = sanitized.chars().take(keep).collect::<String>();
|
||||
out.push('_');
|
||||
out.push_str(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut h: u32 = 0;
|
||||
for unit in s.encode_utf16() {
|
||||
h = h.wrapping_mul(31).wrapping_add(unit as u32);
|
||||
}
|
||||
format!("{:08x}", h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_input() -> TrackPathInput {
|
||||
TrackPathInput {
|
||||
artist: Some("Radiohead".to_string()),
|
||||
album_artist: None,
|
||||
album: "OK Computer".to_string(),
|
||||
title: "Paranoid Android".to_string(),
|
||||
track_number: Some(6),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_uses_library_segments() {
|
||||
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
|
||||
assert_eq!(
|
||||
rel,
|
||||
PathBuf::from("host_4533")
|
||||
.join("Radiohead")
|
||||
.join("OK Computer")
|
||||
.join("06 - Paranoid Android.mp3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_disc_adds_disc_prefix() {
|
||||
let mut input = sample_input();
|
||||
input.disc_number = Some(2);
|
||||
let rel = relative_path_for_track("srv", &input, "flac");
|
||||
assert!(rel
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_uses_album_artist_folder() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("Various Artists".to_string()),
|
||||
album_artist: Some("Original Soundtrack".to_string()),
|
||||
album: "Film Score".to_string(),
|
||||
title: "Main Theme".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_artist_falls_back_to_various_artists() {
|
||||
let input = TrackPathInput {
|
||||
artist: None,
|
||||
album_artist: None,
|
||||
album: "Comp".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_fingerprint_is_stable() {
|
||||
let a = layout_fingerprint(&sample_input());
|
||||
let b = layout_fingerprint(&sample_input());
|
||||
assert_eq!(a, b);
|
||||
assert!(a.contains("Radiohead"));
|
||||
assert!(a.contains("OK Computer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_subdirs_are_fixed() {
|
||||
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
|
||||
assert_eq!(LocalTier::Library.subdir(), "library");
|
||||
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
|
||||
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
|
||||
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
|
||||
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_includes_tier() {
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dot_metadata_does_not_escape_tier_root() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("..".to_string()),
|
||||
album_artist: None,
|
||||
album: "..".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_hash_matches_ts_imul31_utf16() {
|
||||
// "Radiohead" — same as mediaLayout.test parity anchor.
|
||||
assert_eq!(short_hash("Radiohead"), "3da68c3b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_and_truncate_uses_code_point_threshold() {
|
||||
let cyrillic_a = '\u{0430}';
|
||||
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
|
||||
assert!(hundred.len() > MAX_SEGMENT_LEN);
|
||||
assert_eq!(hundred.chars().count(), 100);
|
||||
assert_eq!(
|
||||
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
|
||||
hundred
|
||||
);
|
||||
|
||||
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
|
||||
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
|
||||
assert!(truncated.ends_with("_eef20600"));
|
||||
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album(
|
||||
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
|
||||
}
|
||||
|
||||
/// Upsert Subsonic API song payloads into the library index so pin/download can
|
||||
/// build `media/library/…` paths before a full sync has ingested the rows.
|
||||
#[tauri::command]
|
||||
pub fn library_upsert_songs_from_api(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
songs: Vec<serde_json::Value>,
|
||||
) -> Result<u32, String> {
|
||||
use crate::sync::subsonic_song_to_track_row;
|
||||
use psysonic_integration::subsonic::Song;
|
||||
|
||||
if songs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let synced_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs() as i64;
|
||||
let repo = TrackRepository::new(&runtime.store);
|
||||
let mut rows = Vec::with_capacity(songs.len());
|
||||
for raw in songs {
|
||||
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
|
||||
rows.push(subsonic_song_to_track_row(
|
||||
&server_id,
|
||||
&song,
|
||||
&raw,
|
||||
synced_at,
|
||||
None,
|
||||
));
|
||||
}
|
||||
repo.upsert_batch(&rows)?;
|
||||
Ok(rows.len() as u32)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_artifact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
|
||||
@@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// All live rows for a Subsonic track id (any server). Used when legacy offline
|
||||
/// folders name the server by URL index key rather than profile UUID.
|
||||
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
|
||||
let rows = stmt
|
||||
.query_map(params![track_id], row_to_track_row)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
}
|
||||
|
||||
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
|
||||
/// preserve their order in the result; unknown / deleted refs
|
||||
/// are silently dropped (frontend reads `tracks.length` against
|
||||
@@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy offline rows keyed by library `server_id` (index key scope).
|
||||
pub fn list_offline_local_paths(
|
||||
&self,
|
||||
server_id: &str,
|
||||
) -> Result<Vec<(String, String, Option<String>)>, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![server_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
|
||||
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
|
||||
pub fn list_analysis_hash_bpm_ids_after(
|
||||
@@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE id = ?1 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
|
||||
@@ -3,10 +3,12 @@ name = "psysonic-syncfs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
psysonic-library = { path = "../psysonic-library" }
|
||||
psysonic-analysis = { path = "../psysonic-analysis" }
|
||||
psysonic-audio = { path = "../psysonic-audio" }
|
||||
|
||||
|
||||
+100
-2
@@ -1,4 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Recursively sums the size of all files under `root`.
|
||||
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
|
||||
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
|
||||
total
|
||||
}
|
||||
|
||||
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
|
||||
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
|
||||
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
|
||||
let mut current = path.parent()?;
|
||||
loop {
|
||||
match current.file_name().and_then(|s| s.to_str()) {
|
||||
Some("cache") | Some("library") | Some("favorites") => {
|
||||
return Some(current.to_path_buf());
|
||||
}
|
||||
_ => current = current.parent()?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
|
||||
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
|
||||
/// boundary is reached, or removal fails.
|
||||
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
|
||||
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
|
||||
/// not under `boundary`, the function is a no-op.
|
||||
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
|
||||
let boundary_norm = normalize_path_for_prefix(boundary);
|
||||
let mut current = Some(start_dir.to_path_buf());
|
||||
while let Some(dir) = current {
|
||||
if dir == boundary || !dir.starts_with(boundary) {
|
||||
let dir_norm = normalize_path_for_prefix(&dir);
|
||||
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
|
||||
break;
|
||||
}
|
||||
match std::fs::read_dir(&dir) {
|
||||
@@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
|
||||
pub fn prune_empty_subdirs_under(root: &Path) {
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
let children: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
for child in children {
|
||||
prune_empty_subdirs_under(&child);
|
||||
let is_empty = std::fs::read_dir(&child)
|
||||
.map(|mut rd| rd.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
let _ = std::fs::remove_dir(&child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -87,6 +153,17 @@ mod tests {
|
||||
assert!(path.exists(), "boundary dir must never be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_regular_files_under_lists_nested_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
|
||||
let sub = dir.path().join("Artist/Album");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
|
||||
let files = collect_regular_files_under(dir.path());
|
||||
assert_eq!(files.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -98,4 +175,25 @@ mod tests {
|
||||
assert!(!child.exists(), "empty leaf should be pruned");
|
||||
assert!(parent.exists(), "non-empty parent must stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tier_boundary_from_path_finds_cache_root() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let track = root
|
||||
.path()
|
||||
.join("vol/media/cache/my.server/Artist/Album/track.flac");
|
||||
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
|
||||
let boundary = local_tier_boundary_from_path(&track).unwrap();
|
||||
assert_eq!(boundary, root.path().join("vol/media/cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
prune_empty_subdirs_under(&root.path().join("cache"));
|
||||
assert!(!cache.exists());
|
||||
assert!(root.path().join("cache").exists(), "tier root preserved");
|
||||
}
|
||||
}
|
||||
|
||||
+1422
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ mod fs_utils;
|
||||
pub mod offline;
|
||||
pub mod downloads;
|
||||
pub mod hot;
|
||||
pub mod local;
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod cli;
|
||||
mod cover_cache;
|
||||
mod library_analysis_backfill;
|
||||
mod lib_commands;
|
||||
mod theme_import;
|
||||
pub mod theme_animation;
|
||||
|
||||
pub use psysonic_integration::discord;
|
||||
|
||||
@@ -114,6 +116,38 @@ pub fn run() {
|
||||
let _ = window.set_title("Psysonic (Dev)");
|
||||
}
|
||||
|
||||
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
|
||||
// Poll a local theme.css and push it into the running app on save,
|
||||
// so theme authors get a live loop without re-importing a zip. The
|
||||
// frontend (dev only) installs it under the id in its
|
||||
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
|
||||
match args.get(i + 1).cloned() {
|
||||
Some(path) => {
|
||||
eprintln!("[theme-watch] watching {path}");
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
let mut last_css = String::new();
|
||||
loop {
|
||||
if let Ok(css) = std::fs::read_to_string(&p) {
|
||||
if css != last_css {
|
||||
last_css = css.clone();
|
||||
let _ = handle.emit("theme-watch:css", css);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
}
|
||||
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Analysis cache (SQLite) ───────────────────────────────────
|
||||
{
|
||||
let cache = analysis_cache::AnalysisCache::init(app.handle())
|
||||
@@ -593,6 +627,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
theme_import::import_theme_zip,
|
||||
backup_export_library_db,
|
||||
backup_import_library_db,
|
||||
backup_export_full,
|
||||
@@ -619,6 +654,7 @@ pub fn run() {
|
||||
performance_cpu_snapshot,
|
||||
set_subsonic_wire_user_agent,
|
||||
no_compositing_mode,
|
||||
theme_animation_risk,
|
||||
linux_xdg_session_type,
|
||||
is_tiling_wm_cmd,
|
||||
open_mini_player,
|
||||
@@ -715,6 +751,7 @@ pub fn run() {
|
||||
psysonic_library::commands::library_get_track,
|
||||
psysonic_library::commands::library_get_tracks_batch,
|
||||
psysonic_library::commands::library_get_tracks_by_album,
|
||||
psysonic_library::commands::library_upsert_songs_from_api,
|
||||
psysonic_library::commands::library_get_artifact,
|
||||
psysonic_library::commands::library_get_facts,
|
||||
psysonic_library::commands::library_get_offline_path,
|
||||
@@ -775,6 +812,19 @@ pub fn run() {
|
||||
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
||||
psysonic_syncfs::cache::offline::delete_offline_track,
|
||||
psysonic_syncfs::cache::offline::get_offline_cache_size,
|
||||
psysonic_syncfs::cache::local::download_track_local,
|
||||
psysonic_syncfs::cache::local::probe_library_track_local,
|
||||
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
|
||||
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
|
||||
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
|
||||
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
|
||||
psysonic_syncfs::cache::local::probe_media_files,
|
||||
psysonic_syncfs::cache::local::get_media_tier_size,
|
||||
psysonic_syncfs::cache::local::purge_media_tier,
|
||||
psysonic_syncfs::cache::local::delete_media_file,
|
||||
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
|
||||
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
|
||||
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
|
||||
psysonic_syncfs::cache::hot::download_track_hot_cache,
|
||||
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
|
||||
psysonic_syncfs::cache::hot::get_hot_cache_size,
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(crate) use perf::performance_cpu_snapshot;
|
||||
pub(crate) use platform::{
|
||||
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
|
||||
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
|
||||
theme_animation_risk,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use platform::{
|
||||
|
||||
@@ -137,6 +137,29 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
|
||||
}
|
||||
|
||||
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
|
||||
/// Tauri command: true when theme animations may be costly on this setup —
|
||||
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
|
||||
/// compositing forced off. The frontend warns on animated themes when true.
|
||||
/// Always false off Linux.
|
||||
#[tauri::command]
|
||||
pub(crate) fn theme_animation_risk() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Compositing forced off → GPU-accelerated effects/animation are costly.
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
crate::theme_animation::nvidia_quirk_active()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||
if let Some(win) = app_handle.get_webview_window("main") {
|
||||
|
||||
@@ -17,11 +17,16 @@ fn apply_linux_webkit_nvidia_quirk() {
|
||||
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
|
||||
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
|
||||
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
|
||||
// Detect once and record it for the UI's animated-theme CPU-load warning,
|
||||
// so the theme_animation_risk command never has to re-probe the GPU.
|
||||
let kind = needs_workaround();
|
||||
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
|
||||
|
||||
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
|
||||
matches!(s.split(',').next().map(str::trim), Some("x11"))
|
||||
});
|
||||
if forced_x11_gdk {
|
||||
match needs_workaround() {
|
||||
match kind {
|
||||
WorkaroundKind::None => {}
|
||||
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
|
||||
set_webkit_disable_dmabuf_renderer();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Startup-recorded display hint for the theme system.
|
||||
//!
|
||||
//! The Nvidia/WebKit GPU quirk is detected once at process start (in `main()`,
|
||||
//! before the webview/GTK init). We record whether it was needed so the UI can
|
||||
//! warn that animated themes may raise CPU load on this setup — without
|
||||
//! re-probing the GPU later. Read via the `theme_animation_risk` command
|
||||
//! (`lib_commands::app_api::platform`).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static NVIDIA_QUIRK_ACTIVE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Record the startup Nvidia-WebKit-quirk detection. Called once from `main()`.
|
||||
pub fn set_nvidia_quirk_active(active: bool) {
|
||||
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
|
||||
}
|
||||
|
||||
/// Whether the Nvidia WebKit quirk was needed at startup. False when unrecorded
|
||||
/// (non-Linux, or GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`).
|
||||
pub(crate) fn nvidia_quirk_active() -> bool {
|
||||
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Local theme-package import.
|
||||
//!
|
||||
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
|
||||
//! to the frontend, which runs the full theme-store contract validation
|
||||
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
|
||||
//!
|
||||
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
|
||||
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
|
||||
//! in Rust, outside the webview, and every read is size-capped so a malformed
|
||||
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
|
||||
//! memory or escape the archive.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// On-disk archive cap. A real token-only theme zip is a few KB.
|
||||
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// Per-entry uncompressed caps — mirror the frontend/CI limits
|
||||
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
|
||||
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
const MAX_CSS_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImportedThemeFiles {
|
||||
pub manifest: String,
|
||||
pub css: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
|
||||
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
|
||||
let len = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("cannot read file info: {e}"))?
|
||||
.len();
|
||||
if len > MAX_ARCHIVE_BYTES {
|
||||
return Err(format!(
|
||||
"archive is too large (> {} KB)",
|
||||
MAX_ARCHIVE_BYTES / 1024
|
||||
));
|
||||
}
|
||||
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
|
||||
|
||||
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
|
||||
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
|
||||
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
|
||||
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
|
||||
|
||||
Ok(ImportedThemeFiles { manifest, css })
|
||||
}
|
||||
|
||||
/// Find the first non-directory entry whose file name equals `wanted` (at the
|
||||
/// archive root or under a single wrapping folder), reject path traversal, and
|
||||
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
|
||||
/// actual read are bounded, so a lying header cannot allocate past the cap.
|
||||
fn read_capped_entry<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
wanted: &str,
|
||||
cap: usize,
|
||||
) -> Result<Option<String>, String> {
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("corrupt archive entry: {e}"))?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
|
||||
let base = match entry.enclosed_name() {
|
||||
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => continue,
|
||||
},
|
||||
None => return Err("archive contains an unsafe path".to_string()),
|
||||
};
|
||||
if base != wanted {
|
||||
continue;
|
||||
}
|
||||
if entry.size() > cap as u64 {
|
||||
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
entry
|
||||
.by_ref()
|
||||
.take(cap as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
|
||||
if buf.len() > cap {
|
||||
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||
}
|
||||
return String::from_utf8(buf)
|
||||
.map(Some)
|
||||
.map_err(|_| format!("{wanted} is not valid UTF-8"));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
+56
@@ -1,6 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useLyricsStore } from './store/lyricsStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from './utils/themes/themeInjection';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { getWindowKind } from './app/windowKind';
|
||||
@@ -13,14 +17,66 @@ export default function App() {
|
||||
useThemeStore(s => s.theme);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const installedThemes = useInstalledThemesStore(s => s.themes);
|
||||
|
||||
// Document-attribute hooks are shared between both window kinds — each
|
||||
// webview has its own `document`, and theme / font / track-preview tokens
|
||||
// are read by CSS in both trees.
|
||||
|
||||
// Installed community themes have no build-time CSS — inject their
|
||||
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
|
||||
// store. Runs before the data-theme effect below so the matching style exists
|
||||
// when the attribute is applied. The store hydrates synchronously, so an
|
||||
// active community theme is painted without a network round-trip.
|
||||
useEffect(() => {
|
||||
syncInjectedThemes(installedThemes);
|
||||
}, [installedThemes]);
|
||||
|
||||
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
|
||||
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
|
||||
// selector and apply it — the syncInjectedThemes effect above re-injects, so
|
||||
// authoring is live without re-importing a zip. Never wired in production.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void import('@tauri-apps/api/event').then(({ listen }) => {
|
||||
const sub = listen<string>('theme-watch:css', ({ payload }) => {
|
||||
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
|
||||
if (!id) return;
|
||||
useInstalledThemesStore.getState().install({
|
||||
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
|
||||
});
|
||||
useThemeStore.getState().setTheme(id);
|
||||
});
|
||||
// Guard the mocked-in-tests case where listen() isn't a promise.
|
||||
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
|
||||
}).catch(() => {});
|
||||
return () => unlisten?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
// Expose app state on the theme root so themes can react to it with a
|
||||
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
|
||||
// The set of allowed state attributes is the contract's `stateSelectors`.
|
||||
// (Sidebar-collapsed is set in AppShell, where that state lives.)
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
|
||||
}, [isPlaying]);
|
||||
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
|
||||
}, [isFullscreenOpen]);
|
||||
|
||||
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
|
||||
}, [lyricsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
@@ -473,6 +473,18 @@ export function libraryGetTrack(
|
||||
.then(track => (track ? { ...track, serverId } : track));
|
||||
}
|
||||
|
||||
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
|
||||
export function libraryUpsertSongsFromApi(
|
||||
serverId: string,
|
||||
songs: unknown[],
|
||||
): Promise<number> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
|
||||
}
|
||||
|
||||
/** `library_get_tracks_batch` cap (spec §7.1). */
|
||||
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
|
||||
|
||||
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
const indexKeyMap = new Map<string, string>();
|
||||
const remapped = refs.map(ref => {
|
||||
@@ -487,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
|
||||
})));
|
||||
}
|
||||
|
||||
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
|
||||
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
if (refs.length === 0) return [];
|
||||
const out: LibraryTrackDto[] = [];
|
||||
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
|
||||
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
|
||||
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
|
||||
out.push(...batch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function libraryGetTracksByAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
|
||||
@@ -20,6 +20,11 @@ vi.mock('axios', () => ({
|
||||
default: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: () => true,
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
import axios from 'axios';
|
||||
import { pingWithCredentials, ping } from './subsonic';
|
||||
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
shouldAttemptSubsonicForActiveServer,
|
||||
shouldAttemptSubsonicForServer,
|
||||
} from '../utils/network/subsonicNetworkGuard';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import type {
|
||||
RandomSongsFilters,
|
||||
@@ -57,6 +61,7 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||
}
|
||||
|
||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'random',
|
||||
size,
|
||||
@@ -71,6 +76,7 @@ export async function getAlbumList(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type,
|
||||
size,
|
||||
@@ -212,6 +218,7 @@ export async function getAlbumListForServer(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return [];
|
||||
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
|
||||
type,
|
||||
size,
|
||||
@@ -224,6 +231,7 @@ export async function getAlbumListForServer(
|
||||
}
|
||||
|
||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return null;
|
||||
try {
|
||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
@@ -233,6 +241,7 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
}
|
||||
|
||||
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
|
||||
try {
|
||||
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
@@ -242,6 +251,9 @@ export async function getSongForServer(serverId: string, id: string): Promise<Su
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
@@ -251,6 +263,9 @@ export async function getAlbumForServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { api } from './subsonicClient';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
|
||||
@@ -19,6 +20,22 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
|
||||
return { playlist, songs: entry ?? [] };
|
||||
}
|
||||
|
||||
export async function getPlaylistForServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getPlaylist.view',
|
||||
{ id },
|
||||
);
|
||||
const { entry, ...playlist } = data.playlist;
|
||||
return { playlist, songs: entry ?? [] };
|
||||
}
|
||||
|
||||
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
|
||||
const params: Record<string, unknown> = { name };
|
||||
if (songIds && songIds.length > 0) {
|
||||
@@ -40,6 +57,9 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
|
||||
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
|
||||
});
|
||||
}
|
||||
void import('../utils/offline/pinnedOfflineSync')
|
||||
.then(m => m.schedulePinnedPlaylistSync(id))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function updatePlaylistMeta(
|
||||
|
||||
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
|
||||
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { getArtist } from './subsonicArtists';
|
||||
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getArtist } from './subsonicArtists';
|
||||
import { getAlbum } from './subsonicLibrary';
|
||||
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||
@@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
@@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
|
||||
@@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({
|
||||
api: vi.fn(),
|
||||
apiForServer: apiForServerMock,
|
||||
}));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
describe('subsonicScrobble', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicNowPlaying } from './subsonicTypes';
|
||||
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
async function scrobbleOnServer(
|
||||
serverId: string,
|
||||
@@ -8,6 +9,7 @@ async function scrobbleOnServer(
|
||||
submission: boolean,
|
||||
time?: number,
|
||||
): Promise<void> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
|
||||
const params: Record<string, unknown> = { id, submission };
|
||||
if (time !== undefined) params.time = time;
|
||||
await apiForServer(serverId, 'scrobble.view', params);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, libraryFilterParams } from './subsonicClient';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
|
||||
@@ -15,6 +15,17 @@ import type {
|
||||
SubsonicSong,
|
||||
} from './subsonicTypes';
|
||||
|
||||
function parseStarred2Response(data: {
|
||||
starred2?: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}): StarredResults {
|
||||
const r = data.starred2 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
|
||||
export async function getStarred(): Promise<StarredResults> {
|
||||
const data = await api<{
|
||||
starred2: {
|
||||
@@ -23,21 +34,50 @@ export async function getStarred(): Promise<StarredResults> {
|
||||
song?: SubsonicSong[];
|
||||
}
|
||||
}>('getStarred2.view', { ...libraryFilterParams() });
|
||||
const r = data.starred2 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
return parseStarred2Response(data);
|
||||
}
|
||||
|
||||
/** Starred entities for an explicit saved server (not necessarily the active one). */
|
||||
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
|
||||
const data = await apiForServer<{
|
||||
starred2: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
|
||||
return parseStarred2Response(data);
|
||||
}
|
||||
|
||||
function resolveStarServerId(meta?: StarPatchMeta): string | null {
|
||||
return meta?.serverId ?? useAuthStore.getState().activeServerId;
|
||||
}
|
||||
|
||||
async function starApi(
|
||||
serverId: string | null | undefined,
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid) throw new Error('No server for star API');
|
||||
if (sid === useAuthStore.getState().activeServerId) {
|
||||
await api(endpoint, params);
|
||||
} else {
|
||||
await apiForServer(sid, endpoint, params);
|
||||
}
|
||||
}
|
||||
|
||||
export async function star(
|
||||
id: string,
|
||||
type: 'song' | 'album' | 'artist' = 'album',
|
||||
_meta?: StarPatchMeta,
|
||||
meta?: StarPatchMeta,
|
||||
): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
await api('star.view', params);
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'star.view', params);
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
|
||||
} else if (type === 'album' && serverId) {
|
||||
@@ -45,19 +85,22 @@ export async function star(
|
||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
||||
}
|
||||
void import('../utils/offline/favoritesOfflineSync')
|
||||
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function unstar(
|
||||
id: string,
|
||||
type: 'song' | 'album' | 'artist' = 'album',
|
||||
_meta?: StarPatchMeta,
|
||||
meta?: StarPatchMeta,
|
||||
): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
await api('unstar.view', params);
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'unstar.view', params);
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
|
||||
} else if (type === 'album' && serverId) {
|
||||
@@ -65,6 +108,9 @@ export async function unstar(
|
||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
||||
}
|
||||
void import('../utils/offline/favoritesOfflineSync')
|
||||
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function setRating(id: string, rating: number): Promise<void> {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface SubsonicAlbum {
|
||||
displayArtist?: string;
|
||||
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
|
||||
discTitles?: SubsonicDiscTitle[];
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicDiscTitle {
|
||||
@@ -90,6 +92,8 @@ export interface SubsonicSong {
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
/** OpenSubsonic: structured composer credit (string for back-compat). */
|
||||
displayComposer?: string;
|
||||
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
|
||||
@@ -144,6 +148,8 @@ export interface SubsonicArtist {
|
||||
starred?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||
userRating?: number;
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
|
||||
+24
-7
@@ -11,16 +11,19 @@ import PlayerBar from '../components/PlayerBar';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import LiveSearch from '../components/LiveSearch';
|
||||
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
|
||||
import NowPlayingDropdown from '../components/NowPlayingDropdown';
|
||||
import QueuePanel from '../components/QueuePanel';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import FullscreenPlayer from '../components/FullscreenPlayer';
|
||||
import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic';
|
||||
import ContextMenu from '../components/ContextMenu';
|
||||
import SongInfoModal from '../components/SongInfoModal';
|
||||
import DownloadFolderModal from '../components/DownloadFolderModal';
|
||||
import GlobalConfirmModal from '../components/GlobalConfirmModal';
|
||||
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
|
||||
import OrbitAccountPicker from '../components/OrbitAccountPicker';
|
||||
import OrbitHelpModal from '../components/OrbitHelpModal';
|
||||
import OrbitReconnectModal from '../components/OrbitReconnectModal';
|
||||
import TooltipPortal from '../components/TooltipPortal';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
import {
|
||||
@@ -38,7 +41,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
|
||||
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
||||
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
||||
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
||||
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
||||
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
||||
@@ -51,11 +55,11 @@ import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'
|
||||
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
|
||||
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
||||
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
||||
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import '../store/previewPlayerVolumeSync';
|
||||
import '../store/queueResolverBridge';
|
||||
@@ -105,8 +109,10 @@ export function AppShell() {
|
||||
useLiveSearchRouteScope();
|
||||
useNowPlayingPrewarm();
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const hasOfflineContent = offlineCtx.hasBrowsingContent;
|
||||
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
@@ -134,7 +140,8 @@ export function AppShell() {
|
||||
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
||||
}, [location.pathname, location.state]);
|
||||
|
||||
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
|
||||
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
|
||||
useOfflineLibraryFilterSuspend();
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
@@ -173,6 +180,13 @@ export function AppShell() {
|
||||
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
|
||||
}, [isSidebarCollapsed, setSidebarCollapsed]);
|
||||
|
||||
// Expose sidebar state on the theme root so themes can react with a
|
||||
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
|
||||
// `stateSelectors`). Other state attributes are set in App.tsx.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
|
||||
}, [isSidebarCollapsed]);
|
||||
|
||||
// Workaround for WebKitGTK 2.50.x text-input repaint hang on
|
||||
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
|
||||
// nudge WebKit awake on every input/textarea focus via a sync
|
||||
@@ -241,6 +255,7 @@ export function AppShell() {
|
||||
<div className="main-content-zoom">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
{import.meta.env.DEV && <DevNetworkModeToggle />}
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
@@ -259,7 +274,7 @@ export function AppShell() {
|
||||
</header>
|
||||
<OrbitSessionBar />
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body app-shell-route-host">
|
||||
<OverlayScrollArea
|
||||
@@ -305,8 +320,10 @@ export function AppShell() {
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<ThemeMigrationNotice />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
<OrbitReconnectModal />
|
||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||
<AppUpdater />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
|
||||
import { initHotCachePrefetch } from '../hotCachePrefetch';
|
||||
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
|
||||
import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync';
|
||||
import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync';
|
||||
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins';
|
||||
import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration';
|
||||
import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile';
|
||||
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
|
||||
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
|
||||
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
|
||||
@@ -98,6 +104,28 @@ export default function MainApp() {
|
||||
return initHotCachePrefetch();
|
||||
}, [migrationReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!migrationReady) return undefined;
|
||||
void (async () => {
|
||||
await runLegacyOfflineFileMigration();
|
||||
const servers = useAuthStore.getState().servers;
|
||||
for (const server of servers) {
|
||||
await reconcileLibraryTierForServer(server.id);
|
||||
}
|
||||
scheduleResumeIncompleteOfflinePins();
|
||||
})();
|
||||
const stopInvalidation = initLocalPlaybackInvalidation();
|
||||
const stopFavoritesSync = initFavoritesOfflineSync();
|
||||
const stopPinnedOfflineSync = initPinnedOfflineSync();
|
||||
const stopOfflineResume = initResumeIncompleteOfflinePins();
|
||||
return () => {
|
||||
stopInvalidation();
|
||||
stopFavoritesSync();
|
||||
stopPinnedOfflineSync();
|
||||
stopOfflineResume();
|
||||
};
|
||||
}, [migrationReady, serverIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!migrationReady) return;
|
||||
useGlobalShortcutsStore.getState().registerAll();
|
||||
|
||||
@@ -24,10 +24,14 @@ vi.mock('./windowKind', () => ({
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getWindowKind } from './windowKind';
|
||||
import {
|
||||
applyThemeAtStartup,
|
||||
installCrossWindowThemeSync,
|
||||
pushLoggingModeToBackend,
|
||||
pushUserAgentToBackend,
|
||||
runPreReactBootstrap,
|
||||
} from './bootstrap';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useInstalledThemesStore } from '../store/installedThemesStore';
|
||||
|
||||
const ORIGINAL_USER_AGENT = window.navigator.userAgent;
|
||||
|
||||
@@ -48,6 +52,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
setUserAgent(ORIGINAL_USER_AGENT);
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe('pushUserAgentToBackend', () => {
|
||||
@@ -143,3 +149,56 @@ describe('runPreReactBootstrap', () => {
|
||||
// the bootstrap doesn't second-guess that decision.
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyThemeAtStartup', () => {
|
||||
const setPersistedTheme = (state: Record<string, unknown>) =>
|
||||
localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 }));
|
||||
|
||||
it('does nothing when there is no persisted theme', () => {
|
||||
applyThemeAtStartup();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets data-theme to the active theme (scheduler off)', () => {
|
||||
setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false });
|
||||
applyThemeAtStartup();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
|
||||
});
|
||||
|
||||
it('injects installed community themes up front', () => {
|
||||
setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false });
|
||||
localStorage.setItem('psysonic_installed_themes', JSON.stringify({
|
||||
state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] },
|
||||
version: 1,
|
||||
}));
|
||||
applyThemeAtStartup();
|
||||
expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
|
||||
});
|
||||
|
||||
it('does not throw on malformed storage', () => {
|
||||
localStorage.setItem('psysonic_theme', '{not json');
|
||||
expect(() => applyThemeAtStartup()).not.toThrow();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installCrossWindowThemeSync', () => {
|
||||
it('rehydrates the matching store on a cross-window storage event', () => {
|
||||
const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined);
|
||||
const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined);
|
||||
installCrossWindowThemeSync();
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' }));
|
||||
expect(themeRehydrate).toHaveBeenCalled();
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' }));
|
||||
expect(installedRehydrate).toHaveBeenCalled();
|
||||
|
||||
themeRehydrate.mockClear();
|
||||
installedRehydrate.mockClear();
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' }));
|
||||
expect(themeRehydrate).not.toHaveBeenCalled();
|
||||
expect(installedRehydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { migrateThemeSelection } from '../utils/themes/themeMigration';
|
||||
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
|
||||
import { syncInjectedThemes } from '../utils/themes/themeInjection';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
|
||||
/** Sync backend HTTP User-Agent from the main webview once at startup. */
|
||||
export function pushUserAgentToBackend(): void {
|
||||
@@ -35,6 +39,63 @@ export function pushLoggingModeToBackend(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes(): InstalledTheme[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_installed_themes');
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } };
|
||||
return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the active theme synchronously, before React mounts, so the first paint
|
||||
* is already correct. Zustand rehydrate + the `data-theme` effect run after the
|
||||
* first paint, so without this a non-Mocha active theme (every light theme and
|
||||
* every installed community theme) flashes the `:root` Mocha default for a
|
||||
* frame. We set `data-theme` to the effective (scheduler-resolved) theme and
|
||||
* inject installed community themes' CSS up front. Runs after the migration so
|
||||
* the persisted ids are already resolved.
|
||||
*/
|
||||
export function applyThemeAtStartup(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_theme');
|
||||
if (!raw) return; // fresh profile — the :root default is correct
|
||||
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
|
||||
const s = parsed.state;
|
||||
if (!s) return;
|
||||
syncInjectedThemes(readInstalledThemes());
|
||||
const effective = getScheduledTheme({
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme ?? 'mocha'),
|
||||
themeDay: String(s.themeDay ?? 'latte'),
|
||||
themeNight: String(s.themeNight ?? 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart ?? '07:00'),
|
||||
timeNightStart: String(s.timeNightStart ?? '19:00'),
|
||||
});
|
||||
if (effective) document.documentElement.setAttribute('data-theme', effective);
|
||||
} catch {
|
||||
// Non-fatal — App's effects apply the theme after mount.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep theme state in sync across webviews (main ↔ mini player). Zustand
|
||||
* persist does not sync across windows on its own; the `storage` event fires in
|
||||
* *other* windows when localStorage changes, so rehydrate the relevant store
|
||||
* there. Installing/applying/uninstalling in one window then live-updates the
|
||||
* other (App's effects re-run and re-apply `data-theme` / injected styles).
|
||||
*/
|
||||
export function installCrossWindowThemeSync(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.();
|
||||
else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.();
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark the document in Vite dev so CSS can show dev-only chrome. */
|
||||
export function markDevBuildDocument(): void {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -46,6 +107,12 @@ export function markDevBuildDocument(): void {
|
||||
export function runPreReactBootstrap(): void {
|
||||
// Pre-warm the window-kind cache so subsequent reads are sync + safe.
|
||||
getWindowKind();
|
||||
// Reset any persisted theme that is no longer bundled and not installed, so
|
||||
// the store hydrates onto a paintable theme (no unstyled-:root flash).
|
||||
migrateThemeSelection();
|
||||
// Paint the correct theme on the very first frame (no Mocha flash).
|
||||
applyThemeAtStartup();
|
||||
installCrossWindowThemeSync();
|
||||
markDevBuildDocument();
|
||||
pushUserAgentToBackend();
|
||||
pushLoggingModeToBackend();
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
@@ -17,12 +16,14 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
||||
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
@@ -63,21 +64,28 @@ function AlbumCard({
|
||||
}: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => playAlbum(album.id),
|
||||
onLongPress: () => playAlbumShuffled(album.id),
|
||||
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isOffline = useOfflineStore(s => {
|
||||
const meta = s.albums[`${serverId}:${album.id}`];
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
});
|
||||
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineServerId = album.serverId ?? activeServerId;
|
||||
const localEntries = useLocalPlaybackStore(s => s.entries);
|
||||
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
|
||||
const albumLinkQuery = useMemo(
|
||||
() => appendServerQuery(linkQuery, album.serverId),
|
||||
[linkQuery, album.serverId],
|
||||
);
|
||||
void localEntries;
|
||||
const psyDrag = useDragDrop();
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(album.serverId),
|
||||
[album.serverId],
|
||||
);
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
|
||||
const dragCoverKey = useMemo(() => {
|
||||
if (!coverRef) return '';
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||
@@ -88,7 +96,7 @@ function AlbumCard({
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
navigateToAlbum(album.id, { search: linkQuery });
|
||||
navigateToAlbum(album.id, { search: albumLinkQuery });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -182,10 +190,13 @@ function AlbumCard({
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
const tracks = await fetchAlbumTracks(
|
||||
album.id,
|
||||
offlineServerId || undefined,
|
||||
);
|
||||
if (tracks.length > 0) enqueue(tracks);
|
||||
} catch {
|
||||
// Network failure — silent (toast would be too noisy for a hover action)
|
||||
// Unavailable offline or network failure — silent on hover action
|
||||
}
|
||||
}}
|
||||
aria-label={t('contextMenu.enqueueAlbum')}
|
||||
|
||||
+129
-94
@@ -19,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes';
|
||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -74,7 +75,7 @@ interface AlbumHeaderProps {
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
offlineStatus: 'none' | 'downloading' | 'cached';
|
||||
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
@@ -91,6 +92,8 @@ interface AlbumHeaderProps {
|
||||
onEntityRatingChange: (rating: number) => void;
|
||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -117,7 +120,9 @@ export default function AlbumHeader({
|
||||
entityRatingValue,
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
actionPolicy,
|
||||
}: AlbumHeaderProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const goBack = useAlbumDetailBack();
|
||||
@@ -222,7 +227,7 @@ export default function AlbumHeader({
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -250,14 +255,16 @@ export default function AlbumHeader({
|
||||
|
||||
{/* Row 2 — Secondary actions */}
|
||||
<div className="album-actions-row album-actions-row--secondary">
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
@@ -269,7 +276,7 @@ export default function AlbumHeader({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
@@ -280,44 +287,57 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.offlineQueued')}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,13 +368,15 @@ export default function AlbumHeader({
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
@@ -366,7 +388,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-bio-btn"
|
||||
@@ -377,47 +399,60 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function AlbumRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{uniqueAlbums.map((a, idx) => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
album={a}
|
||||
showRating={showRating}
|
||||
linkQuery={albumLinkQuery}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useTracklistColumns } from '../utils/useTracklistColumns';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow';
|
||||
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
|
||||
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
|
||||
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
|
||||
|
||||
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
|
||||
sortKey?: SortKey;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: (key: SortKey) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||
@@ -60,7 +62,9 @@ export default function AlbumTrackList({
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
actionPolicy,
|
||||
}: AlbumTrackListProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
@@ -98,6 +102,10 @@ export default function AlbumTrackList({
|
||||
);
|
||||
|
||||
const currentTrackId = currentTrack?.id ?? null;
|
||||
const displayCols = useMemo(
|
||||
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
|
||||
[policy.canFavorite, visibleCols],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
|
||||
>
|
||||
|
||||
<TracklistHeaderRow
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
|
||||
key={song.id}
|
||||
song={song}
|
||||
globalIdx={globalIdx}
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
currentTrackId={currentTrackId}
|
||||
isPlaying={isPlaying}
|
||||
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
|
||||
onToggleSelect={onToggleSelect}
|
||||
onDragStart={onDragStart}
|
||||
setContextMenuSongId={setContextMenuSongId}
|
||||
actionPolicy={policy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -18,12 +20,17 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(artist.serverId),
|
||||
[artist.serverId],
|
||||
);
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
|
||||
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
|
||||
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverRef ? (
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ArtistRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => (
|
||||
<ArtistCardLocal
|
||||
key={a.id}
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
artist={a}
|
||||
linkQuery={artistLinkQuery}
|
||||
libraryResolve={libraryResolve}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
|
||||
interface BackToTopButtonProps {
|
||||
/** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */
|
||||
viewportId?: string;
|
||||
/** Show the button once the viewport is scrolled past this many pixels. */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating "back to top" affordance for long pages. Watches a scroll viewport
|
||||
* (the overlay-scroll element wrapping the routes by default) and, once it is
|
||||
* scrolled past `threshold`, shows a button that smooth-scrolls it back to the
|
||||
* top — reusing the same `getElementById(...).scrollTo` pattern AppShell uses
|
||||
* for its route-change scroll reset.
|
||||
*
|
||||
* The button is portalled into `.app-shell-route-host` and positioned
|
||||
* `absolute` against it: the scroll viewport itself sets `contain: paint`, which
|
||||
* would otherwise make a `position: fixed` child resolve against the *scrolling*
|
||||
* box (so it would drift with the content). The route host is a non-contained,
|
||||
* `position: relative` ancestor that spans exactly the content area (between the
|
||||
* sidebar and queue, above the player bar), so the button stays pinned to the
|
||||
* visible viewport corner regardless of scroll.
|
||||
*/
|
||||
export default function BackToTopButton({
|
||||
viewportId = APP_MAIN_SCROLL_VIEWPORT_ID,
|
||||
threshold = 400,
|
||||
}: BackToTopButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [host, setHost] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
|
||||
const el = document.getElementById(viewportId);
|
||||
if (!el) return;
|
||||
const onScroll = () => setVisible(el.scrollTop > threshold);
|
||||
onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab)
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [viewportId, threshold]);
|
||||
|
||||
if (!visible || !host) return null;
|
||||
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top-btn"
|
||||
onClick={() =>
|
||||
document.getElementById(viewportId)?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
aria-label={t('common.backToTop')}
|
||||
data-tooltip={t('common.backToTop')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<ArrowUp size={18} aria-hidden="true" />
|
||||
</button>,
|
||||
host,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
|
||||
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
@@ -596,7 +597,10 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const data = await resolveAlbum(serverId, album.id);
|
||||
if (!data) return;
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
/* silent — toast would be too noisy for a hover action */
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function CachedImage({
|
||||
};
|
||||
|
||||
const fallbackStyle: React.CSSProperties = isFallback
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
|
||||
useOfflineBrowseContext: () => ({
|
||||
active: false,
|
||||
serverId: 'srv-1',
|
||||
capabilities: {
|
||||
localLibrary: false,
|
||||
favorites: false,
|
||||
playlists: false,
|
||||
manualPins: false,
|
||||
playerStats: false,
|
||||
},
|
||||
hasBrowseCapability: false,
|
||||
hasBrowsingContent: false,
|
||||
connStatus: 'connected' as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
import ContextMenu from './ContextMenu';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
|
||||
@@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
|
||||
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import {
|
||||
offlineActionPolicy,
|
||||
type OfflineSurface,
|
||||
} from '../utils/offline/offlineActionPolicy';
|
||||
import ContextMenuItems from './contextMenu/ContextMenuItems';
|
||||
|
||||
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
case 'multi-album':
|
||||
return 'contextMenuAlbum';
|
||||
case 'artist':
|
||||
case 'multi-artist':
|
||||
return 'contextMenuArtist';
|
||||
case 'playlist':
|
||||
case 'multi-playlist':
|
||||
return 'contextMenuPlaylist';
|
||||
default:
|
||||
return 'contextMenuSong';
|
||||
}
|
||||
}
|
||||
|
||||
export { AddToPlaylistSubmenu };
|
||||
|
||||
|
||||
@@ -180,6 +201,12 @@ export default function ContextMenu() {
|
||||
|
||||
const downloadAlbum = downloadAlbumAction;
|
||||
|
||||
const { active: offlineBrowseActive } = useOfflineBrowseContext();
|
||||
const offlinePolicy = offlineActionPolicy(
|
||||
contextMenuSurfaceForType(type),
|
||||
offlineBrowseActive,
|
||||
);
|
||||
|
||||
if (!contextMenu.isOpen || !contextMenu.item) return null;
|
||||
|
||||
return (
|
||||
@@ -233,6 +260,7 @@ export default function ContextMenu() {
|
||||
isStarred={isStarred}
|
||||
pinToPlaybackServer={pinToPlaybackServer}
|
||||
navigateLibrary={navigateLibrary}
|
||||
offlinePolicy={offlinePolicy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Cloud, CloudOff } from 'lucide-react';
|
||||
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
|
||||
|
||||
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
|
||||
export default function DevNetworkModeToggle() {
|
||||
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
|
||||
onClick={toggle}
|
||||
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
|
||||
aria-pressed={forceOffline}
|
||||
>
|
||||
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
|
||||
<span>{forceOffline ? 'Offline' : 'Online'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* `FullscreenPlayer` characterization (Phase F5c).
|
||||
*
|
||||
* Includes the §4.5 regression test from the v2 plan — the cover image
|
||||
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
|
||||
* third argument selects the fallback path that avoids a double
|
||||
* crossfade (fetchUrl → blobUrl). A refactor that "tidies up" the
|
||||
* useCachedUrl call sites would silently regress the FS player.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
star: vi.fn(async () => undefined),
|
||||
unstar: vi.fn(async () => undefined),
|
||||
getLyricsBySongId: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
|
||||
// so we can assert the third positional arg `false` is preserved.
|
||||
vi.mock('./CachedImage', async () => {
|
||||
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
|
||||
return {
|
||||
...actual,
|
||||
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
|
||||
};
|
||||
});
|
||||
|
||||
import FullscreenPlayer from './FullscreenPlayer';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — render', () => {
|
||||
it('renders the labelled Fullscreen Player dialog', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the Close Fullscreen button', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
// The component calls `useCachedUrl` twice:
|
||||
// - line 338: for the small art box (default behaviour, opt=true)
|
||||
// - line 674: for the cover (opt=false, no fetchUrl fallback)
|
||||
// The `false` arg is load-bearing — it avoids a double crossfade by
|
||||
// routing through the cache-only path. Pin it.
|
||||
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const coverCall = calls.find(c => c[2] === false);
|
||||
expect(coverCall).toBeDefined();
|
||||
expect(typeof coverCall?.[1]).toBe('string');
|
||||
expect(String(coverCall?.[1])).toContain('album-1');
|
||||
});
|
||||
|
||||
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
||||
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — control wiring', () => {
|
||||
it('clicking Close Fullscreen calls the onClose prop', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const onClose = vi.fn();
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={onClose} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Close Fullscreen'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Stop calls stop()', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Stop'));
|
||||
expect(stopSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Previous Track calls previous()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 1,
|
||||
currentTrack: makeTrack({ id: 'b' }),
|
||||
});
|
||||
usePlayerStore.setState({ currentTime: 5 });
|
||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Previous Track'));
|
||||
expect(prevSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Next Track calls next()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 0,
|
||||
currentTrack: makeTrack({ id: 'a' }),
|
||||
});
|
||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Next Track'));
|
||||
expect(nextSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Repeat cycles via toggleRepeat', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('off');
|
||||
fireEvent.click(getByLabelText('Repeat'));
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('all');
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { getCachedBlob } from '../utils/imageCache';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
|
||||
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
|
||||
import { FsArt } from './fullscreenPlayer/FsArt';
|
||||
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
|
||||
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
|
||||
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
|
||||
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
|
||||
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
|
||||
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../hooks/useQueueTracks';
|
||||
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
// Derive isStarred inside the selector so we only re-render when the boolean
|
||||
// actually flips — not when any unrelated track's star status changes.
|
||||
const isStarred = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return false;
|
||||
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
|
||||
});
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||
|
||||
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
|
||||
const artUrl = artCover.src;
|
||||
const artKey = artCover.cacheKey;
|
||||
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
||||
const coverUrl = portraitCover.src;
|
||||
const coverKey = portraitCover.cacheKey;
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Dynamic accent color extracted from the current album cover, applied as
|
||||
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
||||
// same-album tracks reuse the color without re-fetching.
|
||||
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
|
||||
// (thin-state): the next ref resolves from the cache (the prefetch window
|
||||
// around the current index keeps it warm).
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
useEffect(() => {
|
||||
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
|
||||
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
|
||||
nextTrack.albumId,
|
||||
nextTrack.coverArt,
|
||||
300,
|
||||
);
|
||||
getCachedBlob(url, key).catch(() => {});
|
||||
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
|
||||
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const fsControlsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
|
||||
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
|
||||
|
||||
const metaParts = useMemo(() => [
|
||||
currentTrack?.album,
|
||||
currentTrack?.year?.toString(),
|
||||
currentTrack?.suffix?.toUpperCase(),
|
||||
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
|
||||
].filter(Boolean), [currentTrack]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fs-player"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
data-lyrics={isAppleMode || undefined}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
'--fs-portrait-dim': String(fsPortraitDim / 100),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
<div className="fs-mesh-blob fs-mesh-blob-a" />
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
|
||||
</div>
|
||||
|
||||
{/* Track title — massive statement */}
|
||||
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
|
||||
|
||||
{/* Artist — secondary, below track */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
{metaParts.length > 0 && (
|
||||
<div className="fs-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fs-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls" ref={fsControlsRef}>
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
|
||||
<Square size={13} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
|
||||
<button
|
||||
ref={lyricsMenuTriggerRef}
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Layer 4 — full-width seekbar, bottom edge */}
|
||||
<FsSeekbar duration={duration} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+42
-15
@@ -1,4 +1,5 @@
|
||||
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import { getRandomAlbums } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
@@ -33,14 +34,25 @@ function HeroBg({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counter = useRef(1);
|
||||
const counter = useRef(url ? 1 : 0);
|
||||
const latestUrlRef = useRef(url);
|
||||
latestUrlRef.current = url;
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
if (!url) {
|
||||
setLayers([]);
|
||||
return;
|
||||
}
|
||||
const id = counter.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
|
||||
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
|
||||
const t1 = setTimeout(() => {
|
||||
if (latestUrlRef.current !== url) return;
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
}, 20);
|
||||
const t2 = setTimeout(() => {
|
||||
if (latestUrlRef.current !== url) return;
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 900);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]);
|
||||
|
||||
@@ -259,7 +271,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!album || albumFormats[album.id] !== undefined) return;
|
||||
getAlbum(album.id).then(data => {
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
resolveAlbum(serverId, album.id).then(data => {
|
||||
if (!data) {
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
|
||||
return;
|
||||
}
|
||||
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
|
||||
}).catch(() => {
|
||||
@@ -273,17 +291,20 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
|
||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||
// transition (which would cause the background to flash empty before fading in).
|
||||
const stableBgUrl = useRef('');
|
||||
// Per-album fallback so a cache miss on the current slide does not flash empty,
|
||||
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
|
||||
const stableBgByAlbum = useRef<Record<string, string>>({});
|
||||
const albumId = album?.id;
|
||||
useEffect(() => {
|
||||
if (bgHandle.src && albumId) {
|
||||
stableBgByAlbum.current[albumId] = bgHandle.src;
|
||||
}
|
||||
}, [bgHandle.src, albumId]);
|
||||
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => { if (albumId) playAlbum(albumId); },
|
||||
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
|
||||
});
|
||||
useEffect(() => {
|
||||
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
|
||||
}, [bgHandle.src, albumId]);
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
@@ -296,7 +317,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={() => navigateToAlbum(album.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
@@ -339,7 +360,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) {}
|
||||
}}
|
||||
@@ -369,7 +393,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function LosslessFilterButton({ active, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings, HardDriveDownload } from 'lucide-react';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
@@ -15,8 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
const { t } = useTranslation();
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
@@ -29,6 +32,15 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
cfg.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
// Scrubber touch/mouse drag
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
|
||||
{visible.length > 0 && (
|
||||
<span style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--ctp-crust)',
|
||||
color: 'var(--text-on-accent)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 6px',
|
||||
@@ -161,7 +161,7 @@ export default function NowPlayingDropdown() {
|
||||
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
|
||||
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
|
||||
{stream.albumId && stream.coverArt ? (
|
||||
<TrackCoverArtImage
|
||||
song={{
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { HardDriveDownload, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatBytes } from '../utils/format/formatBytes';
|
||||
|
||||
const OPEN_DELAY_MS = 450;
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
totalBytes: number | null;
|
||||
libraryBytes: number | null;
|
||||
favoritesBytes: number | null;
|
||||
}
|
||||
|
||||
export function OfflineLibraryDiskStat({
|
||||
label,
|
||||
totalBytes,
|
||||
libraryBytes,
|
||||
favoritesBytes,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
|
||||
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
|
||||
|
||||
const clearOpenTimer = () => {
|
||||
if (openTimerRef.current !== null) {
|
||||
clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleOpen = () => {
|
||||
if (!breakdownReady) return;
|
||||
clearOpenTimer();
|
||||
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearOpenTimer();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const breakdownAriaLabel = breakdownReady
|
||||
? [
|
||||
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
|
||||
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
|
||||
].join('. ')
|
||||
: undefined;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !anchorRef.current || !popoverRef.current) {
|
||||
setStyle({ opacity: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = anchorRef.current.getBoundingClientRect();
|
||||
const box = popoverRef.current.getBoundingClientRect();
|
||||
const GAP = 8;
|
||||
const MARGIN = 8;
|
||||
|
||||
let top = anchor.bottom + GAP;
|
||||
let left = anchor.left + anchor.width / 2 - box.width / 2;
|
||||
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
|
||||
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
|
||||
|
||||
setStyle({ opacity: 1, top, left });
|
||||
}, [open, libraryBytes, favoritesBytes]);
|
||||
|
||||
useEffect(() => () => clearOpenTimer(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={anchorRef}
|
||||
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
|
||||
aria-live="polite"
|
||||
aria-label={breakdownAriaLabel}
|
||||
onMouseEnter={scheduleOpen}
|
||||
onMouseLeave={close}
|
||||
onFocus={scheduleOpen}
|
||||
onBlur={close}
|
||||
tabIndex={breakdownReady ? 0 : undefined}
|
||||
>
|
||||
<span className="offline-library-disk-label">{label}</span>
|
||||
<span className="offline-library-disk-value">
|
||||
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
|
||||
</span>
|
||||
</div>
|
||||
{open && breakdownReady && createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="offline-library-disk-breakdown"
|
||||
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
|
||||
role="tooltip"
|
||||
onMouseEnter={() => {
|
||||
clearOpenTimer();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={close}
|
||||
>
|
||||
<div className="offline-library-disk-breakdown-row">
|
||||
<span
|
||||
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
|
||||
aria-hidden
|
||||
>
|
||||
<HardDriveDownload size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="offline-library-disk-breakdown-size">
|
||||
{formatBytes(libraryBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="offline-library-disk-breakdown-divider" aria-hidden />
|
||||
<div className="offline-library-disk-breakdown-row">
|
||||
<span
|
||||
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
|
||||
aria-hidden
|
||||
>
|
||||
<Heart size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="offline-library-disk-breakdown-size">
|
||||
{formatBytes(favoritesBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -66,14 +66,30 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - anchor.right),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
let style: React.CSSProperties = { display: 'none' };
|
||||
if (anchor) {
|
||||
const vw = window.innerWidth;
|
||||
// Compact, viewport-bounded width (same scale as the other Orbit popovers).
|
||||
const width = Math.min(560, vw - 16);
|
||||
// Anchor under the trigger, but slide inward so the right-anchored popover
|
||||
// never spills off either window edge — on a narrow window the left edge
|
||||
// would otherwise drop off-screen.
|
||||
const right = Math.min(
|
||||
Math.max(8, vw - anchor.right),
|
||||
Math.max(8, vw - width - 8),
|
||||
);
|
||||
style = {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right,
|
||||
width,
|
||||
// Cap height to the space below the anchor (minus a small margin); the
|
||||
// log textarea flex-shrinks to fit. Re-read every render (the 1s
|
||||
// mini-display tick keeps width/height fresh after a window resize).
|
||||
maxHeight: `calc(100vh - ${Math.round(anchor.bottom + 24)}px)`,
|
||||
zIndex: 9999,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Live mini-display data ────────────────────────────────────────────
|
||||
const role = useOrbitStore(s => s.role);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { makeInitialOrbitState } from '../api/orbit';
|
||||
|
||||
const { authState, orbitState, orbitApi } = vi.hoisted(() => ({
|
||||
authState: { isLoggedIn: true, activeServerId: 'srv-1' as string | null, username: 'bob' as string | undefined },
|
||||
orbitState: { role: null as 'host' | 'guest' | null },
|
||||
orbitApi: {
|
||||
readOrbitLastSession: vi.fn(),
|
||||
findSessionPlaylistId: vi.fn(),
|
||||
readOrbitState: vi.fn(),
|
||||
clearOrbitLastSession: vi.fn(),
|
||||
resumeOrbitSessionAsHost: vi.fn(),
|
||||
joinOrbitSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
vi.mock('../store/authStore', () => {
|
||||
const hook = (sel: (s: typeof authState) => unknown) => sel(authState);
|
||||
(hook as unknown as { getState: () => unknown }).getState = () => ({
|
||||
...authState,
|
||||
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
|
||||
});
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
vi.mock('../store/orbitStore', () => {
|
||||
const hook = (sel: (s: typeof orbitState) => unknown) => sel(orbitState);
|
||||
(hook as unknown as { getState: () => unknown }).getState = () => orbitState;
|
||||
return { useOrbitStore: hook };
|
||||
});
|
||||
vi.mock('../utils/ui/toast', () => ({ showToast: vi.fn() }));
|
||||
vi.mock('../utils/orbit', () => ({
|
||||
...orbitApi,
|
||||
ORBIT_RECONNECT_COUNTDOWN_S: 30,
|
||||
ORBIT_RECONNECT_MAX_AGE_MS: 30 * 60_000,
|
||||
}));
|
||||
|
||||
import OrbitReconnectModal from './OrbitReconnectModal';
|
||||
|
||||
const hostBreadcrumb = {
|
||||
sid: 'feedface',
|
||||
sessionPlaylistId: 'pl-1',
|
||||
outboxPlaylistId: 'ob-1',
|
||||
role: 'host' as const,
|
||||
sessionName: 'Night Run',
|
||||
hostUsername: 'bob',
|
||||
serverId: 'srv-1',
|
||||
savedAt: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authState.isLoggedIn = true;
|
||||
authState.activeServerId = 'srv-1';
|
||||
authState.username = 'bob';
|
||||
orbitState.role = null;
|
||||
orbitApi.readOrbitLastSession.mockReturnValue(hostBreadcrumb);
|
||||
orbitApi.findSessionPlaylistId.mockResolvedValue('pl-1');
|
||||
orbitApi.readOrbitState.mockResolvedValue(
|
||||
makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('OrbitReconnectModal', () => {
|
||||
// Regression: StrictMode double-invokes effects (mount → cleanup → mount).
|
||||
// The old one-shot guard let the cancelled first run block the second, so the
|
||||
// prompt never appeared in dev builds. This must survive the double-invoke.
|
||||
it('shows the reconnect prompt under StrictMode for a live host session', async () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
expect(orbitApi.clearOrbitLastSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not prompt when the breadcrumb is for a different server', async () => {
|
||||
authState.activeServerId = 'other-server';
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wipes the breadcrumb and stays silent when the session is gone', async () => {
|
||||
orbitApi.findSessionPlaylistId.mockResolvedValue(null);
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await waitFor(() => expect(orbitApi.clearOrbitLastSession).toHaveBeenCalled());
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not prompt while already bound to a session', async () => {
|
||||
orbitState.role = 'host';
|
||||
render(
|
||||
<StrictMode>
|
||||
<OrbitReconnectModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import {
|
||||
clearOrbitLastSession,
|
||||
findSessionPlaylistId,
|
||||
joinOrbitSession,
|
||||
ORBIT_RECONNECT_COUNTDOWN_S,
|
||||
ORBIT_RECONNECT_MAX_AGE_MS,
|
||||
readOrbitLastSession,
|
||||
readOrbitState,
|
||||
resumeOrbitSessionAsHost,
|
||||
type OrbitLastSession,
|
||||
} from '../utils/orbit';
|
||||
|
||||
/**
|
||||
* Orbit — reconnect prompt shown at startup when the app was restarted while a
|
||||
* session was active. The in-memory Orbit store is gone after a restart, but
|
||||
* the `lastSession` breadcrumb survives; this verifies the session is still
|
||||
* alive on the server and offers a one-click rejoin with a countdown that
|
||||
* auto-rejoins when it reaches zero.
|
||||
*
|
||||
* Host breadcrumbs resume hosting (`resumeOrbitSessionAsHost`); guest
|
||||
* breadcrumbs rejoin via the normal `joinOrbitSession` path. Declining /
|
||||
* Escape / a failed attempt wipes the breadcrumb so it isn't offered again.
|
||||
*/
|
||||
export default function OrbitReconnectModal() {
|
||||
const { t } = useTranslation();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
|
||||
const [candidate, setCandidate] = useState<OrbitLastSession | null>(null);
|
||||
const [secondsLeft, setSecondsLeft] = useState(ORBIT_RECONNECT_COUNTDOWN_S);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const decidedRef = useRef(false);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
// One-shot startup preflight: is there a live session worth offering?
|
||||
//
|
||||
// StrictMode-safe: `decidedRef` is set only *after* the `cancelled` guard
|
||||
// inside the async, never up front. React's dev double-invoke (mount →
|
||||
// cleanup → mount) cancels the first run before it can decide, so the second
|
||||
// (real) mount still runs the check to completion. A network error leaves
|
||||
// `decidedRef` false so a later dependency change retries.
|
||||
useEffect(() => {
|
||||
if (decidedRef.current) return;
|
||||
if (!isLoggedIn || !activeServerId || orbitRole !== null) return;
|
||||
|
||||
const rec = readOrbitLastSession();
|
||||
if (!rec || rec.serverId !== activeServerId) return; // none / different server
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const sessionPlaylistId = await findSessionPlaylistId(rec.sid);
|
||||
if (cancelled) return;
|
||||
if (!sessionPlaylistId) { decidedRef.current = true; clearOrbitLastSession(); return; }
|
||||
const state = await readOrbitState(sessionPlaylistId);
|
||||
if (cancelled) return;
|
||||
if (!state || state.ended) { decidedRef.current = true; clearOrbitLastSession(); return; }
|
||||
// Too long since the last host snapshot → treat as dead, don't offer.
|
||||
if (Date.now() - (state.positionAt ?? 0) > ORBIT_RECONNECT_MAX_AGE_MS) {
|
||||
decidedRef.current = true; clearOrbitLastSession(); return;
|
||||
}
|
||||
// A host breadcrumb only resumes if we're still the session's host.
|
||||
if (rec.role === 'host' && state.host !== useAuthStore.getState().getActiveServer()?.username) {
|
||||
decidedRef.current = true; clearOrbitLastSession(); return;
|
||||
}
|
||||
decidedRef.current = true;
|
||||
setCandidate(rec);
|
||||
} catch {
|
||||
/* network hiccup — keep the breadcrumb + retry on the next dep change */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isLoggedIn, activeServerId, orbitRole]);
|
||||
|
||||
const doReconnect = useCallback(async () => {
|
||||
if (firedRef.current) return; // guard countdown + manual click racing
|
||||
const rec = candidate;
|
||||
if (!rec) return;
|
||||
firedRef.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (rec.role === 'host') await resumeOrbitSessionAsHost(rec.sid);
|
||||
else await joinOrbitSession(rec.sid);
|
||||
setCandidate(null); // success → store now bound, modal hides
|
||||
} catch {
|
||||
clearOrbitLastSession();
|
||||
showToast(t('orbit.reconnect.failed'), 4000, 'error');
|
||||
setCandidate(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [candidate, t]);
|
||||
|
||||
const stayOut = useCallback(() => {
|
||||
clearOrbitLastSession();
|
||||
setCandidate(null);
|
||||
}, []);
|
||||
|
||||
// Countdown → auto-rejoin at zero. Paused while a reconnect is in flight.
|
||||
useEffect(() => {
|
||||
if (!candidate || busy) return;
|
||||
if (secondsLeft <= 0) { void doReconnect(); return; }
|
||||
const id = window.setTimeout(() => setSecondsLeft(s => s - 1), 1000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [candidate, busy, secondsLeft, doReconnect]);
|
||||
|
||||
// Enter → rejoin now; Escape → stay out (explicit dismissal, no auto-rejoin).
|
||||
useEffect(() => {
|
||||
if (!candidate) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void doReconnect(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); stayOut(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [candidate, doReconnect, stayOut]);
|
||||
|
||||
if (!candidate) return null;
|
||||
|
||||
const body = candidate.role === 'host'
|
||||
? t('orbit.reconnect.bodyHost', { name: candidate.sessionName })
|
||||
: t('orbit.reconnect.bodyGuest', { host: candidate.hostUsername, name: candidate.sessionName });
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-exit-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) stayOut(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-reconnect-title"
|
||||
>
|
||||
<div className="modal-content orbit-exit-modal">
|
||||
<h3 id="orbit-reconnect-title" className="orbit-exit-modal__title">{t('orbit.reconnect.title')}</h3>
|
||||
<p className="orbit-exit-modal__body">{body}</p>
|
||||
{!busy && (
|
||||
<p className="orbit-reconnect-countdown">{t('orbit.reconnect.autoIn', { seconds: secondsLeft })}</p>
|
||||
)}
|
||||
<div className="orbit-exit-modal__actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={stayOut} disabled={busy}>
|
||||
{t('orbit.reconnect.stayOut')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void doReconnect()} disabled={busy} autoFocus>
|
||||
{busy ? t('orbit.reconnect.reconnecting') : t('orbit.reconnect.rejoin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function PSmallLogo({ className, style }: Props) {
|
||||
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--accent)" />
|
||||
<stop offset="100%" stopColor="var(--ctp-blue)" />
|
||||
<stop offset="100%" stopColor="var(--accent-2)" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
|
||||
@@ -10,6 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
pingWithCredentials: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
@@ -136,7 +135,7 @@ export default function PlayerBar() {
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
@@ -180,7 +179,7 @@ export default function PlayerBar() {
|
||||
}, [volume, setVolume, utilityOverflow]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
const playerBarContent = (
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function PsysonicLogo({ className, style, gradientIdSuffix }: Pro
|
||||
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
|
||||
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
|
||||
<stop offset="100%" stopColor="var(--logo-color-end, var(--accent-2))" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
|
||||
@@ -156,16 +156,16 @@ describe('QueuePanel — display mode', () => {
|
||||
expect(container.textContent).toContain('No upcoming tracks');
|
||||
});
|
||||
|
||||
it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => {
|
||||
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
// The mode toggle is the first .queue-action-btn in the header (the
|
||||
// collapse chevron is the second). The default mode is 'queue', so the
|
||||
// toggle's label names its target ("Playlist").
|
||||
// collapse chevron is the second). The toggle's label names its target;
|
||||
// from the default 'queue' that is the next mode in the cycle, "Timeline".
|
||||
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Playlist');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
|
||||
toggle!.click();
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('playlist');
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { resolvePlaylist, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
@@ -33,6 +34,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
|
||||
import { QueueList } from './queuePanel/QueueList';
|
||||
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
||||
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
||||
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
|
||||
|
||||
export default function QueuePanel() {
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
@@ -170,11 +172,12 @@ function QueuePanelHostOrSolo() {
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (queueItems.length === 0) return;
|
||||
const exportTrackIds = activeServerQueueTrackIds(queueItems);
|
||||
if (exportTrackIds.length === 0) return;
|
||||
if (activePlaylist) {
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
|
||||
await updatePlaylist(activePlaylist.id, exportTrackIds);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 1500);
|
||||
} catch (e) {
|
||||
@@ -196,7 +199,8 @@ function QueuePanelHostOrSolo() {
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
if (queueItems.length === 0) {
|
||||
const ids = activeServerQueueTrackIds(queueItems);
|
||||
if (ids.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
@@ -207,7 +211,6 @@ function QueuePanelHostOrSolo() {
|
||||
if (!active) return;
|
||||
const srv = serverShareBaseUrl(active);
|
||||
if (!srv) return;
|
||||
const ids = queueItems.map(r => r.trackId);
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
@@ -381,7 +384,7 @@ function QueuePanelHostOrSolo() {
|
||||
onSave={async (name) => {
|
||||
try {
|
||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
|
||||
const pl = await createPlaylist(name, activeServerQueueTrackIds(queueItems));
|
||||
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||
setSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
@@ -396,7 +399,10 @@ function QueuePanelHostOrSolo() {
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async (id, name, mode) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const data = await resolvePlaylist(serverId, id);
|
||||
if (!data) return;
|
||||
const tracks: Track[] = data.songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
if (mode === 'append') {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
@@ -23,7 +23,9 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
|
||||
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
||||
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
||||
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||
@@ -41,15 +43,24 @@ export default function Sidebar({
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
||||
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const pinQueue = useOfflineJobStore(s => s.pinQueue);
|
||||
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const activePin = pinQueue.find(p => p.status === 'downloading')
|
||||
?? pinQueue.find(p => p.status === 'queued');
|
||||
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
|
||||
const cancelAllDownloads = () => {
|
||||
clearOfflinePinTasks();
|
||||
cancelAllDownloadsStore();
|
||||
};
|
||||
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
|
||||
const syncJobDone = useDeviceSyncJobStore(s => s.done);
|
||||
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
|
||||
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
||||
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
||||
const isSyncing = syncJobStatus === 'running';
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const musicFolders = useAuthStore(s => s.musicFolders);
|
||||
@@ -61,7 +72,8 @@ export default function Sidebar({
|
||||
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
@@ -82,7 +94,7 @@ export default function Sidebar({
|
||||
}, [playlistsRaw]);
|
||||
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
|
||||
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
|
||||
|
||||
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
|
||||
const selectedFolderName =
|
||||
@@ -101,13 +113,34 @@ export default function Sidebar({
|
||||
libraryItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[libraryItemsForReorder, luckyMixAvailable],
|
||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => c.visible),
|
||||
[systemItemsForReorder],
|
||||
() => systemItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[systemItemsForReorder, isServerOffline, offlineNav],
|
||||
);
|
||||
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
@@ -140,10 +173,15 @@ export default function Sidebar({
|
||||
|
||||
|
||||
const pickLibrary = (id: 'all' | string) => {
|
||||
if (isServerOffline) return;
|
||||
setMusicLibraryFilter(id);
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isServerOffline) setLibraryDropdownOpen(false);
|
||||
}, [isServerOffline, setLibraryDropdownOpen]);
|
||||
|
||||
// Fetch playlists when expanded
|
||||
useEffect(() => {
|
||||
if (!playlistsExpanded || !isLoggedIn) return;
|
||||
@@ -231,6 +269,8 @@ export default function Sidebar({
|
||||
nowPlayingAtTop={nowPlayingAtTop}
|
||||
hasOfflineContent={hasOfflineContent}
|
||||
activeJobsCount={activeJobs.length}
|
||||
activePinName={activePin?.albumName ?? null}
|
||||
queuedPinCount={queuedPinCount}
|
||||
cancelAllDownloads={cancelAllDownloads}
|
||||
isSyncing={isSyncing}
|
||||
syncJobDone={syncJobDone}
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
|
||||
if (size === 'compact') {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { readThemeMigrationNotice, clearThemeMigrationNotice } from '../utils/themes/themeMigration';
|
||||
|
||||
/**
|
||||
* One-time, dismissible notice shown after the slim-bundle migration reset a
|
||||
* theme that is now store-only or retired. Reads the flag written by
|
||||
* `migrateThemeSelection` at startup, points the user at the Theme Store, and
|
||||
* clears the flag on dismiss so it never shows again.
|
||||
*/
|
||||
export default function ThemeMigrationNotice() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [themes] = useState(() => readThemeMigrationNotice());
|
||||
const [open, setOpen] = useState(themes.length > 0);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => {
|
||||
clearThemeMigrationNotice();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t('settings.themeMigrationNoticeTitle')}
|
||||
message={t('settings.themeMigrationNoticeBody', { themes: themes.join(', ') })}
|
||||
confirmLabel={t('settings.themeMigrationNoticeOpen')}
|
||||
cancelLabel={t('common.close')}
|
||||
onConfirm={() => {
|
||||
close();
|
||||
navigate('/settings', { state: { tab: 'themes' } });
|
||||
}}
|
||||
onCancel={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ThemeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
card: string;
|
||||
accent: string;
|
||||
family?: string;
|
||||
}
|
||||
|
||||
export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Games',
|
||||
themes: [
|
||||
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
|
||||
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
|
||||
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
||||
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#06060f', card: '#0a0a1a', accent: '#FCEE0A' },
|
||||
{ id: 'tetrastack', label: 'TetraStack', bg: '#060614', card: '#0c0c20', accent: '#00f0f0' },
|
||||
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
|
||||
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
|
||||
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Movies',
|
||||
themes: [
|
||||
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
||||
{ id: 'dune', label: 'Dune', bg: '#1c1408', card: '#0e0c1a', accent: '#c8780a' },
|
||||
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#0d0b18', card: '#141120', accent: '#ff8c00' },
|
||||
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f0e0b0', card: '#241a0e', accent: '#d4a820' },
|
||||
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
|
||||
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
|
||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
||||
{ id: 't-800', label: 'T-800', bg: '#140e0e', card: '#1a0a0a', accent: '#ff2000' },
|
||||
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
|
||||
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Open Source Classics',
|
||||
themes: [
|
||||
// ── 1984 (juanmnl/vs-1984 — only variants with a distinct palette)
|
||||
{ id: 'vs-1984', label: 'Default', bg: '#0d0f31', card: '#161a4a', accent: '#46BDFF', family: '1984' },
|
||||
{ id: 'vs-1984-cyberpunk', label: 'Cyberpunk', bg: '#1C1E27', card: '#232631', accent: '#85EEA7', family: '1984' },
|
||||
{ id: 'vs-1984-light', label: 'Light', bg: '#e4e5f5', card: '#d4d6e7', accent: '#4d5eff', family: '1984' },
|
||||
{ id: 'vs-1984-orwell', label: 'Orwell', bg: '#2e2923', card: '#3a342c', accent: '#fcd395', family: '1984' },
|
||||
// ── Atom One (Th3Whit3Wolf/one-nvim — Atom One Dark / Light derivative)
|
||||
{ id: 'one-dark', label: 'Dark', bg: '#282c34', card: '#2c323c', accent: '#61afef', family: 'Atom One' },
|
||||
{ id: 'one-light', label: 'Light', bg: '#fafafa', card: '#ececed', accent: '#4078f2', family: 'Atom One' },
|
||||
// ── Catppuccin (dark → light)
|
||||
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7', family: 'Catppuccin' },
|
||||
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6', family: 'Catppuccin' },
|
||||
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6', family: 'Catppuccin' },
|
||||
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef', family: 'Catppuccin' },
|
||||
// ── Dracula
|
||||
{ id: 'dracula', label: 'Dracula', bg: '#282a36', card: '#44475a', accent: '#bd93f9', family: 'Dracula' },
|
||||
// ── Gruvbox (dark → light, hard → soft)
|
||||
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614', family: 'Gruvbox' },
|
||||
// ── Kanagawa (default → variants)
|
||||
{ id: 'kanagawa-wave', label: 'Wave', bg: '#1F1F28', card: '#2A2A37', accent: '#7E9CD8', family: 'Kanagawa' },
|
||||
{ id: 'kanagawa-dragon', label: 'Dragon', bg: '#181616', card: '#282727', accent: '#8ba4b0', family: 'Kanagawa' },
|
||||
{ id: 'kanagawa-lotus', label: 'Lotus', bg: '#f2ecbc', card: '#d5cea3', accent: '#4d699b', family: 'Kanagawa' },
|
||||
// ── Nightfox (default → variants, alphabetical)
|
||||
{ id: 'nightfox', label: 'Nightfox', bg: '#192330', card: '#212e3f', accent: '#719cd6', family: 'Nightfox' },
|
||||
{ id: 'carbonfox', label: 'Carbonfox', bg: '#161616', card: '#1c1c1c', accent: '#be95ff', family: 'Nightfox' },
|
||||
{ id: 'dawnfox', label: 'Dawnfox', bg: '#faf4ed', card: '#ebe0df', accent: '#907aa9', family: 'Nightfox' },
|
||||
{ id: 'dayfox', label: 'Dayfox', bg: '#f6f2ee', card: '#dbd1dd', accent: '#2848a9', family: 'Nightfox' },
|
||||
{ id: 'duskfox', label: 'Duskfox', bg: '#232136', card: '#2d2a45', accent: '#c4a7e7', family: 'Nightfox' },
|
||||
{ id: 'nordfox', label: 'Nordfox', bg: '#2e3440', card: '#39404f', accent: '#81a1c1', family: 'Nightfox' },
|
||||
{ id: 'terafox', label: 'Terafox', bg: '#152528', card: '#1d3337', accent: '#a1cdd8', family: 'Nightfox' },
|
||||
// ── Nord (Polar Night → Snowstorm → Frost → Aurora, official ordering)
|
||||
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0', family: 'Nord' },
|
||||
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac', family: 'Nord' },
|
||||
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0', family: 'Nord' },
|
||||
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead', family: 'Nord' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Operating Systems',
|
||||
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: '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: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
|
||||
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
|
||||
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Psysonic Themes',
|
||||
themes: [
|
||||
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
|
||||
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
|
||||
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
|
||||
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
|
||||
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'COMMUNITY',
|
||||
themes: [
|
||||
{ id: 'amoled-black-pure', label: 'AMOLED Black Pure', bg: '#000000', card: '#050505', accent: '#ffffff' },
|
||||
{ id: 'obsidian-black', label: 'Obsidian Black', bg: '#070706', card: '#11110f', accent: '#dfddd8' },
|
||||
{ id: 'carbon-grey', label: 'Carbon Grey', bg: '#0f0f10', card: '#18181b', accent: '#b4bcc8' },
|
||||
{ id: 'midnight-blue', label: 'Midnight Blue', bg: '#0d1420', card: '#111a28', accent: '#60a5fa' },
|
||||
{ id: 'sepia-dark', label: 'Sepia Dark', bg: '#1e1a14', card: '#252018', accent: '#c8b89a' },
|
||||
{ id: 'volcanic-dark', label: 'Volcanic Dark', bg: '#1b1512', card: '#231b17', accent: '#c97a56' },
|
||||
{ id: 'deep-forest', label: 'Deep Forest', bg: '#141c17', card: '#19231d', accent: '#6aa37c' },
|
||||
{ id: 'violet-haze', label: 'Violet Haze', bg: '#12101d', card: '#171425', accent: '#b4a0ff' },
|
||||
{ id: 'copper-oxide', label: 'Copper Oxide', bg: '#101a18', card: '#131f1c', accent: '#45b8b0' },
|
||||
{ id: 'sakura-night', label: 'Sakura Night', bg: '#1c1218', card: '#241821', accent: '#d8a6bc' },
|
||||
{ id: 'obsidian-gold', label: 'Obsidian Gold', bg: '#0f0d09', card: '#15120d', accent: '#c9a227' }
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mediaplayer',
|
||||
themes: [
|
||||
{ id: 'winmedplayer', label: 'WinMedPlayer', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
|
||||
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
|
||||
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
|
||||
{ id: 'muma-jukebox', label: 'MuMa Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
|
||||
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
||||
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
||||
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
|
||||
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#d4cc46' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Series',
|
||||
themes: [
|
||||
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#100c08', card: '#090c10', accent: '#c41e1e' },
|
||||
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
|
||||
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
|
||||
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
|
||||
{ id: 'north-park', label: 'North Park', bg: '#F5F1E8', card: '#FFFFFF', accent: '#FF8C00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Famous Albums',
|
||||
themes: [
|
||||
{ id: 'dark-side-of-the-moon', label: 'Dark Side of the Moon (inspired)', bg: '#050505', card: '#0D0D0D', accent: '#9B30FF' },
|
||||
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Accessibility',
|
||||
themes: [
|
||||
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700' },
|
||||
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Social Media',
|
||||
themes: [
|
||||
{ id: 'insta', label: 'Insta', bg: '#121212', card: '#000000', accent: '#E1306C' },
|
||||
{ id: 'readit', label: 'ReadIt', bg: '#030303', card: '#1A1A1B', accent: '#FF4500' },
|
||||
{ id: 'the-book', label: 'The Book', bg: '#F0F2F5', card: '#FFFFFF', accent: '#1877F2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ThemePicker({ value, onChange }: Props) {
|
||||
// All groups collapsed on first render. The blue dot in the header surfaces
|
||||
// which group holds the active theme, so auto-expanding it on mount just
|
||||
// adds visual noise to a screen that already has a long sub-section list.
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(null);
|
||||
|
||||
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
|
||||
|
||||
return (
|
||||
<div className="theme-accordion">
|
||||
{THEME_GROUPS.map(({ group, themes }) => {
|
||||
const isOpen = openGroup === group;
|
||||
const hasActive = themes.some(t => t.id === value);
|
||||
return (
|
||||
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
|
||||
<button className="theme-accordion-header" onClick={() => toggle(group)}>
|
||||
<span>
|
||||
{group}
|
||||
{hasActive && !isOpen && (
|
||||
<span className="theme-accordion-active-dot" />
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown size={15} className="theme-accordion-chevron" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="theme-accordion-content">
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{(() => {
|
||||
let lastFamily: string | undefined;
|
||||
return themes.map((t, i) => {
|
||||
const isActive = value === t.id;
|
||||
const showFamilyHeader = !!t.family && t.family !== lastFamily;
|
||||
lastFamily = t.family;
|
||||
return (
|
||||
<Fragment key={t.id}>
|
||||
{showFamilyHeader && (
|
||||
<div
|
||||
className="theme-family-header"
|
||||
style={{ gridColumn: '1 / -1', marginTop: i === 0 ? 0 : 10 }}
|
||||
>
|
||||
{t.family}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="theme-card-btn"
|
||||
onClick={() => onChange(t.id)}
|
||||
>
|
||||
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
|
||||
<div style={{ background: t.bg, height: '55%' }} />
|
||||
<div style={{ background: t.card, height: '20%' }} />
|
||||
<div style={{ background: t.accent, height: '25%' }} />
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
background: t.accent,
|
||||
border: '1.5px solid rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -219,6 +219,27 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
return () => observer.disconnect();
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Dev-only: a theme's CSS variables can change via Vite HMR without the
|
||||
// `data-theme` attribute changing, so the observer above never fires and the
|
||||
// cached colours + canvas keep the old palette until a manual theme switch
|
||||
// (annoying during theme dev — issue: waveform doesn't reflect CSS var edits).
|
||||
// On every HMR update, drop the colour cache and repaint. `import.meta.hot` is
|
||||
// undefined in production builds, so this whole effect is stripped there.
|
||||
useEffect(() => {
|
||||
// `import.meta.hot` is undefined in prod builds and only a partial stub in
|
||||
// the test/SSR env (has `.on` but not `.off`), so feature-detect both before
|
||||
// wiring — otherwise the cleanup throws under vitest.
|
||||
const hot = import.meta.hot;
|
||||
if (!hot || typeof hot.on !== 'function' || typeof hot.off !== 'function') return;
|
||||
const repaint = () => {
|
||||
invalidateColorCache();
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
};
|
||||
hot.on('vite:afterUpdate', repaint);
|
||||
return () => { hot.off('vite:afterUpdate', repaint); };
|
||||
}, [seekbarStyle]);
|
||||
|
||||
const trackIdRef = useRef(trackId);
|
||||
trackIdRef.current = trackId;
|
||||
const seekRef = useRef(seek);
|
||||
|
||||
@@ -59,11 +59,11 @@ export function SeekbarPreview({ style, label, selected, onClick }: Props) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--ctp-surface1)'}`,
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
|
||||
borderRadius: 8,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||
: 'var(--bg-card, var(--ctp-base))',
|
||||
: 'var(--bg-card, var(--bg-app))',
|
||||
padding: '10px 12px 8px',
|
||||
cursor: 'pointer',
|
||||
width: 130,
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function YearFilterButton({
|
||||
{...tooltipAttrs(t('albums.yearFilterTooltip'), { pos: 'bottom' })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
...(active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}),
|
||||
}}
|
||||
>
|
||||
<CalendarRange size={14} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ListPlus, Search, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useSelectionStore } from '../../store/selectionStore';
|
||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
filterText: string;
|
||||
@@ -12,6 +13,7 @@ interface Props {
|
||||
showPlPicker: boolean;
|
||||
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
t: TFunction;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +33,9 @@ export function AlbumDetailToolbar({
|
||||
showPlPicker,
|
||||
setShowPlPicker,
|
||||
t,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
return (
|
||||
<div className="album-track-toolbar">
|
||||
<div className="album-track-toolbar-filter">
|
||||
@@ -59,22 +63,24 @@ export function AlbumDetailToolbar({
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{policy.canAddToPlaylist && (
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => useSelectionStore.getState().clearAll()}
|
||||
|
||||
@@ -8,12 +8,13 @@ import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { useSelectionStore } from '../../store/selectionStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import StarRating from '../StarRating';
|
||||
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
|
||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
type ContextMenuFn = (
|
||||
x: number,
|
||||
@@ -41,6 +42,7 @@ interface TrackRowProps {
|
||||
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||
setContextMenuSongId: (id: string | null) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +69,9 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
onToggleSelect,
|
||||
onDragStart,
|
||||
setContextMenuSongId,
|
||||
actionPolicy,
|
||||
}: TrackRowProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
@@ -116,7 +120,7 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
|
||||
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'albums');
|
||||
}}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
@@ -171,6 +175,7 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
key="rating"
|
||||
value={ratingValue}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
disabled={!policy.canRate}
|
||||
/>
|
||||
);
|
||||
case 'duration':
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../../store/offlineJobStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
@@ -16,6 +16,7 @@ import type { CoverArtRef } from '../../cover/types';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { tooltipAttrs } from '../tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -41,6 +42,7 @@ interface Props {
|
||||
coverRevision: number;
|
||||
headerCoverFailed: boolean;
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
@@ -49,14 +51,21 @@ export default function ArtistDetailHero({
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('artistDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const artistAlbumIds = useMemo(() => albums.map(a => a.id), [albums]);
|
||||
const { status: artistOfflineStatus, progress: artistOfflineProgress } = useArtistOfflineState(
|
||||
id ?? '',
|
||||
activeServerId,
|
||||
artistAlbumIds,
|
||||
);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
@@ -134,7 +143,7 @@ export default function ArtistDetailHero({
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
disabled={!policy.canRate || artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -163,15 +172,17 @@ export default function ArtistDetailHero({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
@@ -217,28 +228,51 @@ export default function ArtistDetailHero({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
const isDownloading = progress && !isDone;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={!!isDownloading}
|
||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
{!isMobile && (isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{policy.canCacheDiscography && albums.length > 0 && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={
|
||||
artistOfflineStatus === 'downloading'
|
||||
|| artistOfflineStatus === 'queued'
|
||||
|| artistOfflineStatus === 'cached'
|
||||
}
|
||||
onClick={() => {
|
||||
if (id && artist && artistOfflineStatus !== 'cached') {
|
||||
downloadArtist(id, artist.name, activeServerId);
|
||||
}
|
||||
}}
|
||||
data-tooltip={
|
||||
artistOfflineStatus === 'downloading' && artistOfflineProgress
|
||||
? t('artistDetail.offlineDownloading', {
|
||||
done: artistOfflineProgress.done,
|
||||
total: artistOfflineProgress.total,
|
||||
})
|
||||
: artistOfflineStatus === 'queued'
|
||||
? t('artistDetail.offlineQueued')
|
||||
: artistOfflineStatus === 'cached'
|
||||
? t('artistDetail.offlineCached')
|
||||
: t('artistDetail.cacheOffline')
|
||||
}
|
||||
>
|
||||
{artistOfflineStatus === 'downloading'
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: artistOfflineStatus === 'cached'
|
||||
? <Check size={16} />
|
||||
: <HardDriveDownload size={16} />}
|
||||
{!isMobile && (
|
||||
artistOfflineStatus === 'downloading' && artistOfflineProgress
|
||||
? t('artistDetail.offlineDownloading', {
|
||||
done: artistOfflineProgress.done,
|
||||
total: artistOfflineProgress.total,
|
||||
})
|
||||
: artistOfflineStatus === 'queued'
|
||||
? t('artistDetail.offlineQueued')
|
||||
: artistOfflineStatus === 'cached'
|
||||
? t('artistDetail.offlineCached')
|
||||
: t('artistDetail.cacheOffline')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
@@ -82,7 +82,7 @@ export default function ArtistDetailTopTracks({
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview(previewInputFromSong(song), 'artist'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
|
||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||
|
||||
interface AlbumProps {
|
||||
@@ -13,8 +12,13 @@ export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProp
|
||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAlbum(albumId).then((data) => {
|
||||
setResolvedIds(data.songs.map((s) => s.id));
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
resolveAlbum(serverId, albumId).then((data) => {
|
||||
setResolvedIds(data ? data.songs.map((s) => s.id) : []);
|
||||
}).catch(() => setResolvedIds([]));
|
||||
}, [albumId]);
|
||||
|
||||
@@ -40,8 +44,19 @@ export function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: ArtistP
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { albums } = await getArtist(artistId);
|
||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs)));
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
const artistData = await resolveArtist(serverId, artistId);
|
||||
if (!artistData) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
const albumSongs = await Promise.all(
|
||||
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? [])),
|
||||
);
|
||||
setResolvedIds(albumSongs.flat().map(s => s.id));
|
||||
})().catch(() => setResolvedIds([]));
|
||||
}, [artistId]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
|
||||
import { star, unstar } from '../../api/subsonicStarRating';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -22,7 +22,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
pinToPlaybackServer, navigateLibrary,
|
||||
pinToPlaybackServer, navigateLibrary, offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -40,7 +40,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
if (tracks.length === 0) return;
|
||||
playNext(tracks);
|
||||
@@ -48,7 +51,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
enqueue(albumData.songs.map(songToTrack));
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||
@@ -57,57 +63,66 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
const meta = {
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
coverArtId: album.coverArt,
|
||||
year: album.year,
|
||||
};
|
||||
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={album.id}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||
disabled={albumRatingDisabled}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
const meta = {
|
||||
serverId: album.serverId,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
coverArtId: album.coverArt,
|
||||
year: album.year,
|
||||
};
|
||||
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={album.id}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||
disabled={albumRatingDisabled}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`album:${album.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canDownload && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`album:${album.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -130,47 +145,56 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
// Parallel — Navidrome handles concurrent getAlbum requests fine.
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const allTracks = results.flatMap(r => r.songs.map(songToTrack));
|
||||
const results = await Promise.all(albums.map(async a => {
|
||||
const serverId = resolveMediaServerId(a.serverId);
|
||||
if (!serverId) return null;
|
||||
return resolveAlbum(serverId, a.id);
|
||||
}));
|
||||
const allTracks = results
|
||||
.filter((r): r is NonNullable<typeof r> => r != null)
|
||||
.flatMap(r => r.songs.map(songToTrack));
|
||||
enqueue(allTracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={multiAlbumRatingId}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedAlbumRating
|
||||
}
|
||||
disabled={albumRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||
for (const a of albums) applyAlbumRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={multiAlbumRatingId}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedAlbumRating
|
||||
}
|
||||
disabled={albumRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||
for (const a of albums) applyAlbumRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -35,50 +36,64 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
const meta = { name: artist.name, albumCount: artist.albumCount };
|
||||
return starred
|
||||
? unstar(artist.id, 'artist', meta)
|
||||
: star(artist.id, 'artist', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={artist.id}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
||||
disabled={artistRatingDisabled}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
||||
/>
|
||||
</div>
|
||||
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
return starred
|
||||
? unstar(artist.id, 'artist', meta)
|
||||
: star(artist.id, 'artist', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={artist.id}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
||||
disabled={artistRatingDisabled}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -100,40 +115,44 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
{t('contextMenu.selectedArtists', { count: artists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={multiArtistRatingId}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedArtistRating
|
||||
}
|
||||
disabled={artistRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||
for (const a of artists) applyArtistRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={multiArtistRatingId}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedArtistRating
|
||||
}
|
||||
disabled={artistRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||
for (const a of artists) applyArtistRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListMusic, Plus } from 'lucide-react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
@@ -26,7 +26,10 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
||||
setTotalAlbums(albumIds.length);
|
||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||
(async () => {
|
||||
const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
|
||||
const serverId = resolveMediaServerId();
|
||||
const albumSongs = serverId
|
||||
? await Promise.all(albumIds.map(id => resolveAlbum(serverId, id).then(r => r?.songs ?? []).catch(() => [])))
|
||||
: [];
|
||||
const allSongs = albumSongs.flat();
|
||||
setResolvedIds(allSongs.map(s => s.id));
|
||||
})().catch(() => setResolvedIds([]));
|
||||
@@ -34,11 +37,15 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
||||
}, [albumIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
||||
if (!resolved) return;
|
||||
const { songs: existingSongs } = resolved;
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListMusic, Plus } from 'lucide-react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import { getPlaylists } from '../../api/subsonicPlaylists';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
@@ -29,10 +28,18 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||
(async () => {
|
||||
const allSongs: string[] = [];
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
for (const artistId of artistIds) {
|
||||
try {
|
||||
const { albums } = await getArtist(artistId);
|
||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
|
||||
const artistData = await resolveArtist(serverId, artistId);
|
||||
if (!artistData) continue;
|
||||
const albumSongs = await Promise.all(
|
||||
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [])),
|
||||
);
|
||||
allSongs.push(...albumSongs.flat().map(s => s.id));
|
||||
} catch {
|
||||
// Skip failed artists
|
||||
@@ -44,11 +51,15 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
||||
}, [artistIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
||||
if (!resolved) return;
|
||||
const { songs: existingSongs } = resolved;
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -33,18 +34,22 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canEditPlaylist && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
@@ -64,6 +69,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -77,18 +84,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canEditPlaylist && (
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
const { deletePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
@@ -113,6 +123,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred));
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import { queueSongStar } from '../../store/pendingStarSync';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
@@ -27,6 +27,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -80,21 +81,26 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const serverId = resolveMediaServerId(song.serverId);
|
||||
if (!serverId || !song.albumId) return;
|
||||
const albumData = await resolveAlbum(serverId, song.albumId);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
@@ -120,12 +126,14 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred));
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
)}
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
@@ -141,22 +149,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
@@ -164,13 +174,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
{playlistId && playlistSongIndex !== undefined && (
|
||||
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
try {
|
||||
const { songs } = await getPlaylist(playlistId);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, playlistId);
|
||||
if (!resolved) return;
|
||||
const { songs } = resolved;
|
||||
const prevCount = songs.length;
|
||||
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
|
||||
await updatePlaylist(playlistId, updatedIds, prevCount);
|
||||
@@ -232,18 +246,20 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
|
||||
@@ -278,22 +294,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
@@ -301,12 +319,16 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, false);
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, false, song.serverId);
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from 'react';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||
import type { EntityShareKind } from '../../utils/share/shareLink';
|
||||
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
export type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
@@ -56,4 +57,5 @@ export interface ContextMenuItemsProps {
|
||||
/** When true, album/artist links switch to the queue server before routing. */
|
||||
pinToPlaybackServer: boolean;
|
||||
navigateLibrary: (path: string) => void | Promise<void>;
|
||||
offlinePolicy: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface FavoriteSongRowCallbacks {
|
||||
startPreview: (song: SubsonicSong) => void;
|
||||
rate: (songId: string, rating: number) => void;
|
||||
remove: (songId: string) => void;
|
||||
navArtist: (artistId: string) => void;
|
||||
navAlbum: (albumId: string) => void;
|
||||
navArtist: (artistId: string, serverId?: string) => void;
|
||||
navAlbum: (albumId: string, serverId?: string) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -100,12 +100,12 @@ function FavoriteSongRow({
|
||||
);
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist}</span>
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'album': return (
|
||||
<div key="album" className="track-artist-cell">
|
||||
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album}</span>
|
||||
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId, song.serverId); } }}>{song.album}</span>
|
||||
</div>
|
||||
);
|
||||
case 'genre': return (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
useFavoritesOfflineStatus,
|
||||
type FavoritesOfflineSemaphore,
|
||||
} from '../../hooks/useFavoritesOfflineStatus';
|
||||
import {
|
||||
disableFavoritesOfflineSync,
|
||||
scheduleFavoritesOfflineSync,
|
||||
} from '../../utils/offline/favoritesOfflineSync';
|
||||
|
||||
function semaphoreTooltipKey(semaphore: FavoritesOfflineSemaphore): string {
|
||||
switch (semaphore) {
|
||||
case 'red':
|
||||
return 'favorites.offlineSemaphoreError';
|
||||
case 'yellow':
|
||||
return 'favorites.offlineSemaphoreSyncing';
|
||||
case 'green':
|
||||
return 'favorites.offlineSemaphoreSynced';
|
||||
}
|
||||
}
|
||||
|
||||
export default function FavoritesOfflineHeader() {
|
||||
const { t } = useTranslation();
|
||||
const setEnabled = useAuthStore(s => s.setFavoritesOfflineEnabled);
|
||||
const { enabled, semaphore, savedCount, targetCount } = useFavoritesOfflineStatus();
|
||||
|
||||
const semaphoreLabel = semaphore
|
||||
? t(semaphoreTooltipKey(semaphore), { saved: savedCount, total: targetCount })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="favorites-offline-control">
|
||||
{enabled && semaphore && (
|
||||
<span
|
||||
className={`favorites-offline-led favorites-offline-led--${semaphore}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={semaphoreLabel}
|
||||
data-tooltip={semaphoreLabel}
|
||||
data-tooltip-pos="bottom"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="favorites-offline-toggle"
|
||||
data-tooltip={t('favorites.offlineTooltip')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<HardDrive
|
||||
size={16}
|
||||
className={`favorites-offline-disk-icon${enabled ? ' favorites-offline-disk-icon--on' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<label className="toggle-switch" aria-label={t('favorites.offlineTooltip')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={async e => {
|
||||
const next = e.target.checked;
|
||||
if (!next) {
|
||||
await disableFavoritesOfflineSync();
|
||||
} else {
|
||||
setEnabled(true);
|
||||
scheduleFavoritesOfflineSync();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import { useNavigate } from 'react-router-dom';
|
||||
import type { ColDef } from '../../utils/useTracklistColumns';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import { useSelectionStore } from '../../store/selectionStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { appendServerQuery } from '../../utils/navigation/detailServerScope';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
import { useElementClientHeightById } from '../../hooks/useResizeClientHeight';
|
||||
import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering';
|
||||
@@ -122,13 +123,19 @@ export default function FavoritesSongsTracklist({
|
||||
L.playTrack(L.visibleTracks[index], L.visibleTracks);
|
||||
},
|
||||
startPreview: (song) => usePreviewStore.getState().startPreview(
|
||||
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
|
||||
previewInputFromSong(song),
|
||||
'favorites',
|
||||
),
|
||||
rate: (songId, r) => latest.current.handleRate(songId, r),
|
||||
remove: (songId) => latest.current.removeSong(songId),
|
||||
navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`),
|
||||
navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`),
|
||||
navArtist: (artistId, serverId) => {
|
||||
const query = appendServerQuery(undefined, serverId);
|
||||
latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`);
|
||||
},
|
||||
navAlbum: (albumId, serverId) => {
|
||||
const query = appendServerQuery(undefined, serverId);
|
||||
latest.current.navigate(query ? `/album/${albumId}?${query}` : `/album/${albumId}`);
|
||||
},
|
||||
}), []);
|
||||
|
||||
const listWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -4,12 +4,17 @@ import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
import { coverServerScopeForServerId } from '../../cover/serverScope';
|
||||
|
||||
export interface TopFavoriteArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
coverArtId: string;
|
||||
/** Present when favorites are merged across servers. */
|
||||
serverId?: string;
|
||||
/** Raw artist id for song filtering (without server prefix). */
|
||||
artistId?: string;
|
||||
}
|
||||
|
||||
interface TopFavoriteArtistsRowProps {
|
||||
@@ -84,6 +89,7 @@ interface TopFavoriteArtistCardProps {
|
||||
|
||||
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
|
||||
const coverId = artist.coverArtId;
|
||||
const artistEntityId = artist.artistId ?? artist.coverArtId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -94,8 +100,9 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<ArtistCoverArtImage
|
||||
artistId={artist.id}
|
||||
artistId={artistEntityId}
|
||||
coverArt={artist.coverArtId}
|
||||
serverScope={coverServerScopeForServerId(artist.serverId)}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Music } from 'lucide-react';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
|
||||
// Album art box — crossfades layers so old art stays visible while new loads.
|
||||
// Uses 300px thumbnails (portrait fallback uses 500px separately).
|
||||
//
|
||||
// Why onLoad instead of new Image() preload:
|
||||
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
|
||||
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
|
||||
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
|
||||
// before we flip it to 1.
|
||||
export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
|
||||
// true = show raw fetchUrl immediately as fallback while blob resolves.
|
||||
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
|
||||
// Showing the URL directly avoids the multi-second blank wait.
|
||||
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
|
||||
|
||||
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
|
||||
const counter = useRef(0);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blobUrl) return;
|
||||
const id = ++counter.current;
|
||||
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
|
||||
}, [blobUrl]);
|
||||
|
||||
const handleLoad = useCallback((id: number) => {
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
|
||||
}, []);
|
||||
|
||||
if (layers.length === 0) {
|
||||
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(l => (
|
||||
<img
|
||||
key={l.id}
|
||||
src={l.src}
|
||||
className="fs-art"
|
||||
style={{ opacity: l.vis ? 1 : 0 }}
|
||||
onLoad={() => handleLoad(l.id)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
function formatClock(): string {
|
||||
return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone wall-clock for the fullscreen player. Owns its own state so the
|
||||
* static player never re-renders for the time. Ticks every 30s (enough to catch
|
||||
* the minute rollover) and pauses while the window/tab is hidden.
|
||||
*/
|
||||
export const FsClock = memo(function FsClock() {
|
||||
const [time, setTime] = useState(formatClock);
|
||||
|
||||
useEffect(() => {
|
||||
let id: number | undefined;
|
||||
const tick = () => setTime(formatClock());
|
||||
const sync = () => {
|
||||
if (document.hidden) {
|
||||
if (id !== undefined) { clearInterval(id); id = undefined; }
|
||||
} else {
|
||||
tick();
|
||||
if (id === undefined) id = window.setInterval(tick, 30_000);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
return () => {
|
||||
if (id !== undefined) clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <span className="fsp-clock">{time}</span>;
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// Lyrics settings popover — shown above the mic button.
|
||||
export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
|
||||
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside the panel or on Escape.
|
||||
// Ignore clicks on the trigger button so re-clicking it toggles normally
|
||||
// instead of outside-handler closing + click re-opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (triggerRef?.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onMouse);
|
||||
};
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const accent = accentColor ?? 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div className="fslm-panel" ref={panelRef}>
|
||||
<div className="fslm-row">
|
||||
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
|
||||
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLyrics}
|
||||
onChange={e => setLyrics(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
|
||||
{(['rail', 'apple'] as const).map(style => (
|
||||
<button
|
||||
key={style}
|
||||
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
|
||||
onClick={() => setStyle(style)}
|
||||
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
|
||||
>
|
||||
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
|
||||
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fslm-arrow" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user