playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+29 -109
View File
@@ -143,6 +143,9 @@ pub async fn set_settings(
volume_normalization: Option<bool>,
) -> Result<(), String> {
let mut cfg = config::load(&app);
let library_folder_moved = download_dir
.as_deref()
.is_some_and(|d| Some(d) != cfg.download_dir.as_deref());
if let Some(d) = download_dir {
cfg.download_dir = Some(d);
}
@@ -208,6 +211,25 @@ pub async fn set_settings(
}
config::save(&app, &cfg);
// A new library folder is a new library. Re-base immediately rather than
// at the next launch, so what the shelves show is what the folder holds —
// the old behaviour left the previous folder's albums standing forever,
// and emptying the new folder could not remove them.
if library_folder_moved {
let app2 = app.clone();
let root = config::download_dir(&app, &cfg);
tauri::async_runtime::spawn(async move {
let lib = app2.state::<crate::pipeline::LibraryState>().library.clone();
if let Ok(Ok(dropped)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
{
log::info!("library folder changed: dropped {dropped} tracks from the old folder");
}
let _ = crate::pipeline::rescan_library(app2.clone()).await;
let _ = app2.emit("soulseek:library-changed", ());
});
}
// Tighter limits take effect right away, not at the next stream/startup.
if retention_changed {
let app2 = app.clone();
@@ -1263,10 +1285,13 @@ pub struct CatalogHeroPreviewResult {
const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1";
const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16;
/// The CHOSEN hero per artist — a decision about this library, not a render
/// cache — so it travels with the library. (The upscaled results keyed by URL
/// and recipe stay in the machine's cache dir: they regenerate cheaply and
/// would otherwise put gigabytes of derived pixels on the drive.)
fn artist_hero_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_data_dir()
let cfg = config::load(app);
let dir = config::ensure_library_data_dir(app, &cfg)
.map_err(|e| e.to_string())?
.join("artist-heroes");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -4366,7 +4391,7 @@ pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> R
Ok(())
}
// ---- playlists ----
// ---- playlists live in src-tauri/src/playlists.rs ----
fn now_secs() -> i64 {
std::time::SystemTime::now()
@@ -4375,111 +4400,6 @@ fn now_secs() -> i64 {
.unwrap_or(0)
}
#[tauri::command]
pub async fn list_playlists(
lib: State<'_, LibraryState>,
) -> Result<Vec<library::Playlist>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn create_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
name: String,
) -> Result<i64, String> {
let l = lib.library.clone();
let id = tauri::async_runtime::spawn_blocking(move || l.create_playlist(&name, now_secs()))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(id)
}
#[tauri::command]
pub async fn delete_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(())
}
#[tauri::command]
pub async fn playlist_tracks(
lib: State<'_, LibraryState>,
id: i64,
) -> Result<Vec<library::PlaylistTrack>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.playlist_tracks(id).map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
#[derive(serde::Deserialize)]
pub struct TrackInput {
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub path: String,
pub art_path: Option<String>,
}
#[tauri::command]
pub async fn add_to_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
playlist_id: i64,
tracks: Vec<TrackInput>,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || -> library::Result<()> {
for t in tracks {
l.add_to_playlist(
playlist_id,
&library::PlaylistTrack {
position: 0, // assigned by insert
title: t.title,
artist: t.artist,
duration_secs: t.duration_secs,
path: t.path,
art_path: t.art_path,
},
)?;
}
Ok(())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(())
}
#[tauri::command]
pub async fn remove_from_playlist(
lib: State<'_, LibraryState>,
playlist_id: i64,
position: i64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.remove_from_playlist(playlist_id, position))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(())
}
/// Compute (or fetch cached) waveform peaks for a local audio file.
/// Returns `buckets` normalized 0..1 values, or an empty array on failure.
#[tauri::command]