Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP.
320 lines
11 KiB
Rust
320 lines
11 KiB
Rust
//! App config. Non-secret settings live in `config.json`; the Soulseek
|
||
//! password lives in the Windows Credential Manager (via `keyring`).
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::PathBuf;
|
||
use tauri::Manager;
|
||
|
||
const KEYRING_SERVICE: &str = "com.soulstream.app";
|
||
/// Pre-rename service name; stored passwords migrate on first read.
|
||
const LEGACY_KEYRING_SERVICE: &str = "com.trifecta.soulseek-plus";
|
||
/// Pre-rename app-data folder; migrated by `migrate_app_data` at startup.
|
||
const LEGACY_APP_DATA_DIR: &str = "com.trifecta.soulseek-plus";
|
||
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum ArtistHeroUpscaler {
|
||
/// Fixed 3840x2160 GPU master, mip-filtered to the visible window.
|
||
#[default]
|
||
#[serde(alias = "fsr")]
|
||
LiveGpu,
|
||
#[serde(rename = "fsr_legacy")]
|
||
Fsr,
|
||
Lanczos3,
|
||
}
|
||
|
||
/// Ceiling for peer-source formats. `LossyOnly` skips lossless files —
|
||
/// the "don't stream multi-hundred-MB FLACs on this connection" cap.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum SourceQualityCap {
|
||
#[default]
|
||
Any,
|
||
LossyOnly,
|
||
}
|
||
|
||
/// Floor for peer-source quality. Lossless formats satisfy every floor; a
|
||
/// lossy file must carry a known bitrate at or above the named floor.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum SourceQualityFloor {
|
||
#[default]
|
||
Any,
|
||
Kbps192,
|
||
Kbps256,
|
||
Kbps320,
|
||
Lossless,
|
||
}
|
||
|
||
/// What the resolver optimizes among sources that satisfy the cap/floor:
|
||
/// best format (current behavior) or fastest transfer (free slot, upload
|
||
/// speed, short queue — format only breaks ties).
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum SourcePriority {
|
||
#[default]
|
||
Quality,
|
||
Speed,
|
||
}
|
||
|
||
/// Per-mode source-selection preferences; streaming and downloading each
|
||
/// carry their own copy.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct SourcePrefs {
|
||
#[serde(default)]
|
||
pub max: SourceQualityCap,
|
||
#[serde(default)]
|
||
pub min: SourceQualityFloor,
|
||
#[serde(default)]
|
||
pub priority: SourcePriority,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct AppConfig {
|
||
#[serde(default)]
|
||
pub username: String,
|
||
#[serde(default)]
|
||
pub listen_port: Option<u16>,
|
||
/// Where downloads are saved. None → default `<Music>\SoulSeek+`.
|
||
#[serde(default)]
|
||
pub download_dir: Option<String>,
|
||
/// Album-folder pattern below the managed library/download root.
|
||
/// Available placeholders: {artist}, {album}, {date}, {year}.
|
||
/// `\` or `/` in the pattern create subfolders (separators coming from
|
||
/// tag values are flattened to `_`).
|
||
#[serde(default)]
|
||
pub library_layout: Option<String>,
|
||
/// Extra folders to share (the downloads folder is always shared).
|
||
#[serde(default)]
|
||
pub shared_folders: Vec<String>,
|
||
/// Concurrent upload slots offered to peers.
|
||
#[serde(default)]
|
||
pub upload_slots: Option<u32>,
|
||
/// Named platform output endpoint. None follows Windows' default device.
|
||
#[serde(default)]
|
||
pub audio_output_device: Option<String>,
|
||
/// Spatial upscaler used for provider artist heroes below native 4K.
|
||
#[serde(default)]
|
||
pub artist_hero_upscaler: ArtistHeroUpscaler,
|
||
/// Source-selection preferences for streaming.
|
||
#[serde(default)]
|
||
pub stream_source_prefs: SourcePrefs,
|
||
/// Source-selection preferences for downloads/saves.
|
||
#[serde(default)]
|
||
pub download_source_prefs: SourcePrefs,
|
||
/// Replace whatever picture a download arrives with by the canonical
|
||
/// high-resolution cover. None → true. Startup-scanned library files are
|
||
/// never rewritten by this; it applies to fresh downloads only.
|
||
#[serde(default)]
|
||
pub replace_embedded_art: Option<bool>,
|
||
/// Longest edge for covers embedded into audio files. None → 1500.
|
||
/// Some(0) → embed the original untouched. The full-resolution original
|
||
/// is always kept in the art cache and the album folder regardless.
|
||
#[serde(default)]
|
||
pub embed_art_max_edge: Option<u32>,
|
||
/// Write the canonical cover as cover.jpg + folder.jpg in the organized
|
||
/// album folder so Explorer and other players show the same artwork.
|
||
/// None → true.
|
||
#[serde(default)]
|
||
pub write_folder_cover: Option<bool>,
|
||
/// Save every fully streamed track into the library. None → false:
|
||
/// streams stay only in the bounded stream cache below.
|
||
#[serde(default)]
|
||
pub stream_keep_forever: Option<bool>,
|
||
/// Days a streamed track stays in the stream cache after it was last
|
||
/// played. None → 30. Ignored when `stream_keep_forever` is on.
|
||
#[serde(default)]
|
||
pub stream_retention_days: Option<u32>,
|
||
/// Stream cache size cap in MB, oldest evicted first. None → 2048.
|
||
/// Some(0) → unlimited.
|
||
#[serde(default)]
|
||
pub stream_cache_max_mb: Option<u64>,
|
||
/// Loudness normalization (−14 LUFS target, BS.1770 measurement):
|
||
/// every track plays at roughly the same perceived level. None → true.
|
||
#[serde(default)]
|
||
pub volume_normalization: Option<bool>,
|
||
}
|
||
|
||
/// Default longest edge for embedded covers when the user hasn't chosen.
|
||
pub const DEFAULT_EMBED_ART_MAX_EDGE: u32 = 1500;
|
||
|
||
pub fn replace_embedded_art(cfg: &AppConfig) -> bool {
|
||
cfg.replace_embedded_art.unwrap_or(true)
|
||
}
|
||
|
||
/// 0 means "embed the original at full resolution".
|
||
pub fn embed_art_max_edge(cfg: &AppConfig) -> u32 {
|
||
cfg.embed_art_max_edge.unwrap_or(DEFAULT_EMBED_ART_MAX_EDGE)
|
||
}
|
||
|
||
pub fn write_folder_cover(cfg: &AppConfig) -> bool {
|
||
cfg.write_folder_cover.unwrap_or(true)
|
||
}
|
||
|
||
pub const DEFAULT_STREAM_RETENTION_DAYS: u32 = 30;
|
||
pub const DEFAULT_STREAM_CACHE_MAX_MB: u64 = 2048;
|
||
|
||
pub fn stream_keep_forever(cfg: &AppConfig) -> bool {
|
||
cfg.stream_keep_forever.unwrap_or(false)
|
||
}
|
||
|
||
pub fn stream_retention_days(cfg: &AppConfig) -> u32 {
|
||
cfg.stream_retention_days
|
||
.unwrap_or(DEFAULT_STREAM_RETENTION_DAYS)
|
||
.max(1)
|
||
}
|
||
|
||
/// 0 = unlimited.
|
||
pub fn stream_cache_max_mb(cfg: &AppConfig) -> u64 {
|
||
cfg.stream_cache_max_mb
|
||
.unwrap_or(DEFAULT_STREAM_CACHE_MAX_MB)
|
||
}
|
||
|
||
pub fn volume_normalization(cfg: &AppConfig) -> bool {
|
||
cfg.volume_normalization.unwrap_or(true)
|
||
}
|
||
|
||
/// One-time migration after the identifier rename: the app-data dir moved
|
||
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
|
||
/// the old dir into place so the library DB, config and stream cache
|
||
/// survive. Never overwrites existing data at the new location.
|
||
pub fn migrate_app_data(app: &tauri::AppHandle) {
|
||
let Ok(new_dir) = app.path().app_data_dir() else {
|
||
return;
|
||
};
|
||
let Some(parent) = new_dir.parent() else {
|
||
return;
|
||
};
|
||
let old_dir = parent.join(LEGACY_APP_DATA_DIR);
|
||
if !old_dir.is_dir() {
|
||
return;
|
||
}
|
||
let occupied = new_dir.exists()
|
||
&& std::fs::read_dir(&new_dir)
|
||
.map(|mut d| d.next().is_some())
|
||
.unwrap_or(true);
|
||
if occupied {
|
||
return;
|
||
}
|
||
if new_dir.exists() {
|
||
let _ = std::fs::remove_dir(&new_dir);
|
||
}
|
||
match std::fs::rename(&old_dir, &new_dir) {
|
||
Ok(()) => log::info!("migrated app data from {}", old_dir.display()),
|
||
Err(e) => log::warn!(
|
||
"app data migration failed ({e}); old data stays in {}",
|
||
old_dir.display()
|
||
),
|
||
}
|
||
}
|
||
|
||
fn config_path(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||
let dir = app.path().app_data_dir().ok()?;
|
||
std::fs::create_dir_all(&dir).ok()?;
|
||
Some(dir.join("config.json"))
|
||
}
|
||
|
||
pub fn load(app: &tauri::AppHandle) -> AppConfig {
|
||
config_path(app)
|
||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||
.and_then(|s| serde_json::from_str(&s).ok())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
pub fn save(app: &tauri::AppHandle, cfg: &AppConfig) {
|
||
if let Some(p) = config_path(app) {
|
||
if let Ok(json) = serde_json::to_string_pretty(cfg) {
|
||
if let Err(e) = std::fs::write(&p, json) {
|
||
log::warn!("failed to save config to {}: {e}", p.display());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The effective downloads directory: configured, or `<Music>\SoulStream`.
|
||
/// A pre-rename `<Music>\SoulSeek+` folder keeps being used if it exists,
|
||
/// so an existing library doesn't fork into a new empty root.
|
||
pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
|
||
if let Some(d) = &cfg.download_dir {
|
||
if !d.is_empty() {
|
||
return PathBuf::from(d);
|
||
}
|
||
}
|
||
let music = app
|
||
.path()
|
||
.audio_dir()
|
||
.or_else(|_| app.path().home_dir())
|
||
.unwrap_or_else(|_| PathBuf::from("."));
|
||
let legacy = music.join("SoulSeek+");
|
||
if legacy.is_dir() {
|
||
return legacy;
|
||
}
|
||
music.join("SoulStream")
|
||
}
|
||
|
||
pub const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}";
|
||
|
||
pub fn library_layout(cfg: &AppConfig) -> &str {
|
||
cfg.library_layout
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|layout| !layout.is_empty())
|
||
.unwrap_or(DEFAULT_LIBRARY_LAYOUT)
|
||
}
|
||
|
||
// ---- password storage (Windows Credential Manager) ----
|
||
|
||
fn entry(username: &str) -> Option<keyring::Entry> {
|
||
keyring::Entry::new(KEYRING_SERVICE, username).ok()
|
||
}
|
||
|
||
pub fn save_password(username: &str, password: &str) {
|
||
if let Some(e) = entry(username) {
|
||
if let Err(err) = e.set_password(password) {
|
||
log::warn!("failed to store password in credential manager: {err}");
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn load_password(username: &str) -> Option<String> {
|
||
if let Some(pw) = entry(username).and_then(|e| e.get_password().ok()) {
|
||
return Some(pw);
|
||
}
|
||
// Fall back to the pre-rename service entry and migrate it over.
|
||
let legacy = keyring::Entry::new(LEGACY_KEYRING_SERVICE, username).ok()?;
|
||
let pw = legacy.get_password().ok()?;
|
||
save_password(username, &pw);
|
||
let _ = legacy.delete_credential();
|
||
Some(pw)
|
||
}
|
||
|
||
pub fn has_password(username: &str) -> bool {
|
||
!username.is_empty() && load_password(username).is_some()
|
||
}
|
||
|
||
pub fn delete_password(username: &str) {
|
||
if let Some(e) = entry(username) {
|
||
let _ = e.delete_credential();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::ArtistHeroUpscaler;
|
||
|
||
#[test]
|
||
fn legacy_fsr_setting_migrates_to_live_gpu() {
|
||
let migrated: ArtistHeroUpscaler = serde_json::from_str("\"fsr\"").unwrap();
|
||
assert_eq!(migrated, ArtistHeroUpscaler::LiveGpu);
|
||
assert_eq!(ArtistHeroUpscaler::default(), ArtistHeroUpscaler::LiveGpu);
|
||
assert_eq!(
|
||
serde_json::to_string(&ArtistHeroUpscaler::LiveGpu).unwrap(),
|
||
"\"live_gpu\""
|
||
);
|
||
assert_eq!(
|
||
serde_json::to_string(&ArtistHeroUpscaler::Fsr).unwrap(),
|
||
"\"fsr_legacy\""
|
||
);
|
||
}
|
||
}
|