Skip to main content

max / audiofiles

Fix toolbar symbols, drag-and-drop analysis, and streaming playback for large files Toolbar: replace invisible Unicode chars with widely-supported arrows + color state. Import: drag-and-drop now triggers analysis flow so BPM/Key/Duration columns populate. Preview: files >30s decode in background thread with 0.5s pre-fill, no UI freeze. Also includes collections, export, and sidebar improvements from prior session. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-18 20:38 UTC
Commit: 568641a1793f82d30bf11c9b9b5f99d16376281c
Parent: 24f9777
19 files changed, +932 insertions, -101 deletions
@@ -113,12 +113,24 @@ pub(crate) fn fill_cpal_output<T: cpal::SizedSample + cpal::FromSample<f32>>(
113 113 return;
114 114 };
115 115
116 - let total_frames = preview_buf.data.len() / 2;
116 + let total_frames = if guard.streaming {
117 + guard.decoded_frames
118 + } else {
119 + preview_buf.data.len() / 2
120 + };
117 121 let mut pos = guard.position;
118 122 let num_frames = data.len() / channels;
119 123
120 124 for frame in 0..num_frames {
121 125 if pos >= total_frames {
126 + if guard.streaming {
127 + // Still decoding — output silence for remaining samples but keep playing
128 + for sample in &mut data[(frame * channels)..] {
129 + *sample = T::from_sample(0.0f32);
130 + }
131 + guard.position = pos;
132 + return;
133 + }
122 134 // Reached end — stop and silence remaining
123 135 guard.playing = false;
124 136 guard.position = 0;
@@ -162,6 +174,9 @@ mod tests {
162 174 }),
163 175 position: 0,
164 176 playing,
177 + streaming: false,
178 + decoded_frames: 0,
179 + total_frames_estimate: None,
165 180 })
166 181 }
167 182
@@ -251,10 +251,8 @@ impl eframe::App for AudioFilesApp {
251 251 }
252 252 ui.add_space(4.0);
253 253 ui.horizontal(|ui| {
254 - if ui.button("Download").clicked() {
255 - if !status.download_url.is_empty() {
256 - let _ = open::that(&status.download_url);
257 - }
254 + if ui.button("Download").clicked() && !status.download_url.is_empty() {
255 + let _ = open::that(&status.download_url);
258 256 }
259 257 if ui.button("Not Now").clicked() {
260 258 self.update_checker.dismiss();
@@ -29,7 +29,7 @@ struct UpdateResponse {
29 29 }
30 30
31 31 /// Shared update status, polled by the UI each frame.
32 - #[derive(Clone)]
32 + #[derive(Clone, Default)]
33 33 pub struct UpdateStatus {
34 34 pub available: bool,
35 35 pub version: String,
@@ -38,18 +38,6 @@ pub struct UpdateStatus {
38 38 pub dismissed: bool,
39 39 }
40 40
41 - impl Default for UpdateStatus {
42 - fn default() -> Self {
43 - Self {
44 - available: false,
45 - version: String::new(),
46 - notes: String::new(),
47 - download_url: String::new(),
48 - dismissed: false,
49 - }
50 - }
51 - }
52 -
53 41 /// Handle to the update checker. Clone-cheap (Arc-wrapped).
54 42 #[derive(Clone)]
55 43 pub struct UpdateChecker {
@@ -15,10 +15,11 @@ use audiofiles_core::db::Database;
15 15 use audiofiles_core::export::profile::DeviceProfileSummary;
16 16 use audiofiles_core::export::ExportItem;
17 17 use audiofiles_core::search::SearchFilter;
18 + use audiofiles_core::collections::Collection;
18 19 use audiofiles_core::smart_folders::SmartFolder;
19 20 use audiofiles_core::store::SampleStore;
20 21 use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis};
21 - use audiofiles_core::{fingerprint, search, similarity, smart_folders, tags, NodeId, SmartFolderId, VfsId};
22 + use audiofiles_core::{collections, fingerprint, search, similarity, smart_folders, tags, CollectionId, NodeId, SmartFolderId, VfsId};
22 23 use parking_lot::Mutex;
23 24
24 25 use super::{
@@ -431,6 +432,48 @@ impl Backend for DirectBackend {
431 432 Ok(smart_folders::rename_smart_folder(&db, id, new_name)?)
432 433 }
433 434
435 + // --- Collections ---
436 +
437 + fn list_collections(&self) -> BackendResult<Vec<Collection>> {
438 + let db = self.db.lock();
439 + Ok(collections::list_collections(&db)?)
440 + }
441 +
442 + fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult<CollectionId> {
443 + let db = self.db.lock();
444 + Ok(collections::create_collection(&db, name, description)?)
445 + }
446 +
447 + fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()> {
448 + let db = self.db.lock();
449 + Ok(collections::rename_collection(&db, id, new_name)?)
450 + }
451 +
452 + fn delete_collection(&self, id: CollectionId) -> BackendResult<()> {
453 + let db = self.db.lock();
454 + Ok(collections::delete_collection(&db, id)?)
455 + }
456 +
457 + fn add_to_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> {
458 + let db = self.db.lock();
459 + Ok(collections::add_to_collection(&db, collection_id, sample_hash)?)
460 + }
461 +
462 + fn remove_from_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> {
463 + let db = self.db.lock();
464 + Ok(collections::remove_from_collection(&db, collection_id, sample_hash)?)
465 + }
466 +
467 + fn list_collection_members(&self, collection_id: CollectionId) -> BackendResult<Vec<String>> {
468 + let db = self.db.lock();
469 + Ok(collections::list_collection_members(&db, collection_id)?)
470 + }
471 +
472 + fn get_sample_collections(&self, sample_hash: &str) -> BackendResult<Vec<Collection>> {
473 + let db = self.db.lock();
474 + Ok(collections::get_sample_collections(&db, sample_hash)?)
475 + }
476 +
434 477 // --- Analysis ---
435 478
436 479 fn get_analysis(&self, hash: &str) -> BackendResult<Option<AnalysisResult>> {
@@ -18,9 +18,10 @@ use audiofiles_core::analysis::AnalysisResult;
18 18 use audiofiles_core::export::profile::DeviceProfileSummary;
19 19 use audiofiles_core::export::{ExportConfig, ExportItem};
20 20 use audiofiles_core::search::SearchFilter;
21 + use audiofiles_core::collections::Collection;
21 22 use audiofiles_core::smart_folders::SmartFolder;
22 23 use audiofiles_core::vfs::{Vfs, VfsNode, VfsNodeWithAnalysis};
23 - use audiofiles_core::{NodeId, SmartFolderId, VfsId};
24 + use audiofiles_core::{CollectionId, NodeId, SmartFolderId, VfsId};
24 25
25 26 pub use direct::DirectBackend;
26 27
@@ -246,6 +247,32 @@ pub trait Backend: Send + Sync {
246 247 /// Rename a smart folder.
247 248 fn rename_smart_folder(&self, id: SmartFolderId, new_name: &str) -> BackendResult<()>;
248 249
250 + // --- Collections ---
251 +
252 + /// List all collections with member counts.
253 + fn list_collections(&self) -> BackendResult<Vec<Collection>>;
254 +
255 + /// Create a collection. Returns the new ID.
256 + fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult<CollectionId>;
257 +
258 + /// Rename a collection.
259 + fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()>;
260 +
261 + /// Delete a collection (cascades members).
262 + fn delete_collection(&self, id: CollectionId) -> BackendResult<()>;
263 +
264 + /// Add a sample to a collection (no-op if already present).
265 + fn add_to_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()>;
266 +
267 + /// Remove a sample from a collection.
268 + fn remove_from_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()>;
269 +
270 + /// List sample hashes in a collection.
271 + fn list_collection_members(&self, collection_id: CollectionId) -> BackendResult<Vec<String>>;
272 +
273 + /// Get all collections containing a given sample.
274 + fn get_sample_collections(&self, sample_hash: &str) -> BackendResult<Vec<Collection>>;
275 +
249 276 // --- Analysis ---
250 277
251 278 /// Get the full analysis result for a sample, if it exists.
@@ -35,6 +35,12 @@ pub struct PreviewPlayback {
35 35 pub position: usize,
36 36 /// Whether playback is active.
37 37 pub playing: bool,
38 + /// `true` while a background thread is still decoding and appending to the buffer.
39 + pub streaming: bool,
40 + /// Number of stereo frames decoded so far (grows during streaming).
41 + pub decoded_frames: usize,
42 + /// Total frame count from file metadata, for stable cursor display during streaming.
43 + pub total_frames_estimate: Option<usize>,
38 44 }
39 45
40 46 impl PreviewPlayback {
@@ -154,6 +160,196 @@ pub fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> {
154 160 })
155 161 }
156 162
163 + /// Estimate the duration of an audio file (in seconds) by reading codec metadata
164 + /// without decoding. Returns `None` if metadata is unavailable.
165 + #[instrument(skip_all)]
166 + pub fn estimate_duration(path: &Path) -> Option<f64> {
167 + let file = std::fs::File::open(path).ok()?;
168 + let mss = MediaSourceStream::new(Box::new(file), Default::default());
169 +
170 + let mut hint = Hint::new();
171 + if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
172 + hint.with_extension(ext);
173 + }
174 +
175 + let probed = symphonia::default::get_probe()
176 + .format(
177 + &hint,
178 + mss,
179 + &FormatOptions::default(),
180 + &MetadataOptions::default(),
181 + )
182 + .ok()?;
183 +
184 + let track = probed.format.default_track()?;
185 + let params = &track.codec_params;
186 + let n_frames = params.n_frames?;
187 + let sample_rate = params.sample_rate?;
188 + if sample_rate == 0 {
189 + return None;
190 + }
191 + Some(n_frames as f64 / sample_rate as f64)
192 + }
193 +
194 + /// Duration threshold in seconds: files longer than this use streaming decode.
195 + pub const STREAMING_THRESHOLD_SECS: f64 = 30.0;
196 +
197 + /// Number of seconds to pre-fill before enabling playback during streaming.
198 + const PREFILL_SECS: f64 = 0.5;
199 +
200 + /// Spawn a background thread that decodes the file and streams data into the
201 + /// shared `PreviewPlayback`. The buffer is pre-filled with ~0.5s of audio before
202 + /// `playing` is set to `true`, so playback starts without waiting for the full decode.
203 + ///
204 + /// Accepts `Arc<SharedState>` so the background thread can access the preview Mutex.
205 + #[instrument(skip_all)]
206 + pub fn start_streaming_decode(
207 + path: &Path,
208 + shared: &std::sync::Arc<crate::state::SharedState>,
209 + ) -> Result<(), PreviewError> {
210 + let file = std::fs::File::open(path).map_err(|e| PreviewError::Open {
211 + path: path.to_path_buf(),
212 + source: e,
213 + })?;
214 + let mss = MediaSourceStream::new(Box::new(file), Default::default());
215 +
216 + let mut hint = Hint::new();
217 + if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
218 + hint.with_extension(ext);
219 + }
220 +
221 + let probed = symphonia::default::get_probe()
222 + .format(
223 + &hint,
224 + mss,
225 + &FormatOptions::default(),
226 + &MetadataOptions::default(),
227 + )
228 + .map_err(|e| PreviewError::Probe(e.to_string()))?;
229 +
230 + let mut format = probed.format;
231 + let track = format.default_track().ok_or(PreviewError::NoTrack)?;
232 + let track_id = track.id;
233 + let codec_params = track.codec_params.clone();
234 + let source_sample_rate = codec_params.sample_rate.unwrap_or(44100);
235 + let n_frames_estimate = codec_params.n_frames.map(|n| n as usize);
236 + let prefill_frames = (PREFILL_SECS * source_sample_rate as f64) as usize;
237 +
238 + let mut decoder = symphonia::default::get_codecs()
239 + .make(&codec_params, &DecoderOptions::default())
240 + .map_err(|e| PreviewError::Decoder(e.to_string()))?;
241 +
242 + // Set up the initial buffer and playback state (not yet playing)
243 + {
244 + let capacity = n_frames_estimate.unwrap_or(source_sample_rate as usize * 60) * 2;
245 + let mut guard = shared.preview.lock();
246 + guard.buffer = Some(PreviewBuffer {
247 + data: Vec::with_capacity(capacity),
248 + channels: 2,
249 + sample_rate: source_sample_rate,
250 + });
251 + guard.position = 0;
252 + guard.playing = false;
253 + guard.streaming = true;
254 + guard.decoded_frames = 0;
255 + guard.total_frames_estimate = n_frames_estimate;
256 + }
257 +
258 + let shared = std::sync::Arc::clone(shared);
259 + std::thread::spawn(move || {
260 + let mut total_frames = 0usize;
261 + let mut started = false;
262 +
263 + loop {
264 + let packet = match format.next_packet() {
265 + Ok(p) => p,
266 + Err(symphonia::core::errors::Error::IoError(ref e))
267 + if e.kind() == std::io::ErrorKind::UnexpectedEof =>
268 + {
269 + break;
270 + }
271 + Err(_) => break,
272 + };
273 +
274 + if packet.track_id() != track_id {
275 + continue;
276 + }
277 +
278 + let decoded = match decoder.decode(&packet) {
279 + Ok(d) => d,
280 + Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
281 + Err(_) => break,
282 + };
283 +
284 + let spec = *decoded.spec();
285 + let num_frames = decoded.frames();
286 + let num_channels = spec.channels.count();
287 +
288 + let mut sample_buf =
289 + SampleBuffer::<f32>::new(num_frames as u64, *decoded.spec());
290 + sample_buf.copy_interleaved_ref(decoded);
291 + let samples = sample_buf.samples();
292 +
293 + // Convert to interleaved stereo in a local batch
294 + let mut batch = Vec::with_capacity(num_frames * 2);
295 + match num_channels {
296 + 1 => {
297 + for &s in samples {
298 + batch.push(s);
299 + batch.push(s);
300 + }
301 + }
302 + 2 => batch.extend_from_slice(samples),
303 + n => {
304 + for frame in 0..num_frames {
305 + let base = frame * n;
306 + let left = samples.get(base).copied().unwrap_or(0.0);
307 + let right = samples.get(base + 1).copied().unwrap_or(0.0);
308 + batch.push(left);
309 + batch.push(right);
310 + }
311 + }
312 + }
313 +
314 + total_frames += num_frames;
315 +
316 + // Append batch to shared buffer (brief lock)
317 + {
318 + let mut guard = shared.preview.lock();
319 +
320 + // Check cancellation: if playing was set to false externally (stop_preview),
321 + // abort the decode.
322 + if started && !guard.playing {
323 + guard.streaming = false;
324 + return;
325 + }
326 +
327 + if let Some(ref mut buf) = guard.buffer {
328 + buf.data.extend_from_slice(&batch);
329 + }
330 + guard.decoded_frames = total_frames;
331 +
332 + // Start playback once pre-fill threshold is reached
333 + if !started && total_frames >= prefill_frames {
334 + guard.playing = true;
335 + started = true;
336 + }
337 + }
338 + }
339 +
340 + // Decode complete
341 + let mut guard = shared.preview.lock();
342 + guard.streaming = false;
343 + guard.decoded_frames = total_frames;
344 + // If the file was very short and we never hit the prefill threshold, start now
345 + if !started && total_frames > 0 {
346 + guard.playing = true;
347 + }
348 + });
349 +
350 + Ok(())
351 + }
352 +
157 353 #[cfg(test)]
158 354 mod tests {
159 355 use super::*;
@@ -4,6 +4,7 @@ use super::*;
4 4
5 5 impl BrowserState {
6 6 /// Quick-import a single file or directory via drag-and-drop into the current folder.
7 + /// After importing, starts the analysis flow so columns (BPM, Key, etc.) get populated.
7 8 pub fn import_path(&mut self, path: &Path) {
8 9 if !path.exists() {
9 10 self.status = format!("Path not found: {}", path.display());
@@ -12,45 +13,62 @@ impl BrowserState {
12 13
13 14 let vfs_id = self.current_vfs_id();
14 15 let parent_id = self.current_dir;
16 + let mut hashes = Vec::new();
15 17
16 18 if path.is_file() {
17 19 match self.import_single_file(path, vfs_id, parent_id) {
18 - Ok(()) => self.status = format!("Imported: {}", path.display()),
20 + Ok(Some(hash_ext)) => {
21 + self.status = format!("Imported: {}", path.display());
22 + hashes.push(hash_ext);
23 + }
24 + Ok(None) => self.status = format!("Imported: {}", path.display()),
19 25 Err(e) => self.status = format!("Error: {e}"),
20 26 }
21 27 } else if path.is_dir() {
22 28 let mut count = 0;
23 29 let mut errors = 0;
24 - self.import_directory_recursive(path, vfs_id, parent_id, &mut count, &mut errors);
30 + self.import_directory_recursive(path, vfs_id, parent_id, &mut count, &mut errors, &mut hashes);
25 31 self.status = format!("Imported {count} files ({errors} errors)");
26 32 }
27 33
28 34 self.refresh_contents();
35 +
36 + if !hashes.is_empty() {
37 + self.start_analysis_flow(hashes);
38 + }
29 39 }
30 40
31 41 /// Import one audio file: hash into the content-addressed store, then link into the VFS.
32 - /// NameConflict and UNIQUE errors are silently ignored (file already exists in this folder).
42 + /// Returns `Ok(Some((hash, ext)))` on successful import, `Ok(None)` if the file was a
43 + /// duplicate (NameConflict/UNIQUE), or `Err` on failure.
33 44 fn import_single_file(
34 45 &self,
35 46 path: &Path,
36 47 vfs_id: VfsId,
37 48 parent_id: Option<NodeId>,
38 - ) -> Result<(), Box<dyn std::error::Error>> {
49 + ) -> Result<Option<(String, String)>, Box<dyn std::error::Error>> {
39 50 let hash = self.backend.import_file(path)?;
40 51 let name = path
41 52 .file_name()
42 53 .and_then(|n| n.to_str())
43 54 .unwrap_or("unknown")
44 55 .to_string();
56 + let ext = path
57 + .extension()
58 + .and_then(|e| e.to_str())
59 + .unwrap_or("")
60 + .to_string();
45 61
46 62 match self.backend.create_sample_link(vfs_id, parent_id, &name, &hash) {
47 - Ok(_) => {}
48 - Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => {}
63 + Ok(_) => Ok(Some((hash, ext))),
64 + Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => Ok(None),
49 65 Err(crate::backend::BackendError::Core(CoreError::Db(ref sqlite_err)))
50 - if sqlite_err.to_string().contains("UNIQUE") => {}
51 - Err(e) => return Err(Box::new(e)),
66 + if sqlite_err.to_string().contains("UNIQUE") =>
67 + {
68 + Ok(None)
69 + }
70 + Err(e) => Err(Box::new(e)),
52 71 }
53 - Ok(())
54 72 }
55 73
56 74 /// Recursively import a directory tree, mirroring its folder structure in the VFS.
@@ -68,6 +86,7 @@ impl BrowserState {
68 86 parent_id: Option<NodeId>,
69 87 count: &mut usize,
70 88 errors: &mut usize,
89 + hashes: &mut Vec<(String, String)>,
71 90 ) {
72 91 let entries = match fs::read_dir(dir) {
73 92 Ok(e) => e,
@@ -112,10 +131,15 @@ impl BrowserState {
112 131 dir_node_id.or(parent_id),
113 132 count,
114 133 errors,
134 + hashes,
115 135 );
116 136 } else if path.is_file() {
117 137 match self.import_single_file(&path, vfs_id, parent_id) {
118 - Ok(()) => *count += 1,
138 + Ok(Some(hash_ext)) => {
139 + *count += 1;
140 + hashes.push(hash_ext);
141 + }
142 + Ok(None) => *count += 1,
119 143 Err(_) => *errors += 1,
120 144 }
121 145 }
@@ -17,11 +17,12 @@ use audiofiles_core::analysis::waveform::WaveformData;
17 17 use audiofiles_core::analysis::AnalysisResult;
18 18 use audiofiles_core::db::Database;
19 19 use audiofiles_core::error::CoreError;
20 + use audiofiles_core::collections::Collection;
20 21 use audiofiles_core::search::SearchFilter;
21 22 use audiofiles_core::smart_folders::SmartFolder;
22 23 use audiofiles_core::store::SampleStore;
23 24 use audiofiles_core::vfs::{NodeType, Vfs, VfsNode};
24 - use audiofiles_core::{NodeId, VfsId};
25 + use audiofiles_core::{CollectionId, NodeId, VfsId};
25 26 pub use audiofiles_core::vfs::VfsNodeWithAnalysis;
26 27 use parking_lot::Mutex;
27 28
@@ -435,6 +436,13 @@ pub struct BrowserState {
435 436 // Theme
436 437 pub current_theme_id: String,
437 438
439 + // Collections
440 + pub collections: Vec<Collection>,
441 + pub active_collection: Option<CollectionId>,
442 + pub collection_create_input: String,
443 + pub collection_rename_target: Option<(CollectionId, String)>,
444 + pub show_collection_create: bool,
445 +
438 446 // Sync UI state
439 447 pub show_sync_panel: bool,
440 448 pub sync_encryption_input: String,
@@ -484,6 +492,8 @@ impl BrowserState {
484 492 .unwrap_or_else(|e| { warn!("Failed to load tags: {e}"); Vec::new() });
485 493 let smart_folders_list = backend.list_smart_folders(vfs_list[0].id)
486 494 .unwrap_or_else(|e| { warn!("Failed to load smart folders: {e}"); Vec::new() });
495 + let collections_list = backend.list_collections()
496 + .unwrap_or_else(|e| { warn!("Failed to load collections: {e}"); Vec::new() });
487 497
488 498 // Load saved theme preference
489 499 let theme_id = backend.get_config("theme")
@@ -542,6 +552,11 @@ impl BrowserState {
542 552 last_analysis_config: None,
543 553 focus_search: false,
544 554 current_theme_id: theme_id,
555 + collections: collections_list,
556 + active_collection: None,
557 + collection_create_input: String::new(),
558 + collection_rename_target: None,
559 + show_collection_create: false,
545 560 show_sync_panel: false,
546 561 sync_encryption_input: String::new(),
547 562 sync_auth_code_input: String::new(),
@@ -551,6 +566,10 @@ impl BrowserState {
551 566 // --- Preview ---
552 567
553 568 /// Decode a sample by hash and start playback through the shared preview buffer.
569 + ///
570 + /// Short files (<=30s or unknown duration) are decoded fully on the GUI thread.
571 + /// Long files use streaming: a background thread decodes while playback starts
572 + /// after a 0.5s pre-fill, avoiding UI freezes.
554 573 pub fn trigger_preview(&mut self, hash: &str) {
555 574 let ext = self.backend.sample_extension(hash).unwrap_or_default();
556 575
@@ -568,21 +587,43 @@ impl BrowserState {
568 587 return;
569 588 }
570 589
571 - match crate::preview::decode_to_f32(&path) {
572 - Ok(buf) => {
573 - let mut playback = self.shared.preview.lock();
574 - playback.buffer = Some(buf);
575 - playback.position = 0;
576 - playback.playing = true;
577 - self.previewing_hash = Some(hash.to_string());
578 - self.status = format!(
579 - "Playing: {}",
580 - path.file_name().unwrap_or_default().to_string_lossy()
581 - );
590 + let duration = crate::preview::estimate_duration(&path);
591 + let use_streaming = duration.is_some_and(|d| d > crate::preview::STREAMING_THRESHOLD_SECS);
592 +
593 + if use_streaming {
594 + match crate::preview::start_streaming_decode(&path, &self.shared) {
595 + Ok(()) => {
596 + self.previewing_hash = Some(hash.to_string());
597 + self.status = format!(
598 + "Playing: {}",
599 + path.file_name().unwrap_or_default().to_string_lossy()
600 + );
601 + }
602 + Err(e) => {
603 + self.status = format!("Decode error: {e}");
604 + self.previewing_hash = None;
605 + }
582 606 }
583 - Err(e) => {
584 - self.status = format!("Decode error: {e}");
585 - self.previewing_hash = None;
607 + } else {
608 + match crate::preview::decode_to_f32(&path) {
609 + Ok(buf) => {
610 + let mut playback = self.shared.preview.lock();
611 + playback.buffer = Some(buf);
612 + playback.position = 0;
613 + playback.playing = true;
614 + playback.streaming = false;
615 + playback.decoded_frames = 0;
616 + playback.total_frames_estimate = None;
617 + self.previewing_hash = Some(hash.to_string());
618 + self.status = format!(
619 + "Playing: {}",
620 + path.file_name().unwrap_or_default().to_string_lossy()
621 + );
622 + }
623 + Err(e) => {
624 + self.status = format!("Decode error: {e}");
625 + self.previewing_hash = None;
626 + }
586 627 }
587 628 }
588 629 }
@@ -815,6 +856,44 @@ impl BrowserState {
815 856 }
816 857 }
817 858
859 + // --- Collections ---
860 +
861 + /// Refresh the collection list from the database.
862 + pub fn refresh_collections(&mut self) {
863 + self.collections = self.backend.list_collections()
864 + .unwrap_or_else(|e| {
865 + warn!("Failed to load collections: {e}");
866 + Vec::new()
867 + });
868 + }
869 +
870 + /// Activate a collection: show its members in the file list.
871 + pub fn activate_collection(&mut self, id: CollectionId) {
872 + let hashes = match self.backend.list_collection_members(id) {
873 + Ok(h) => h,
874 + Err(e) => {
875 + self.status = format!("Failed to load collection: {e}");
876 + return;
877 + }
878 + };
879 + let vfs_id = self.vfs_list[self.current_vfs_idx].id;
880 + let hash_refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
881 + let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hash_refs)
882 + .unwrap_or_default();
883 + let count = nodes.len();
884 + self.contents = Arc::new(nodes);
885 + self.active_collection = Some(id);
886 + self.similarity_search_hash = None;
887 + self.selection.clear();
888 + self.status = format!("{count} samples in collection");
889 + }
890 +
891 + /// Deactivate collection view and return to normal browsing.
892 + pub fn deactivate_collection(&mut self) {
893 + self.active_collection = None;
894 + self.refresh_contents();
895 + }
896 +
818 897 // --- Similarity search ---
819 898
820 899 /// Find samples similar to the given hash and display them.
@@ -27,9 +27,13 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
27 27 let playback = state.shared.preview.lock();
28 28 if playback.playing {
29 29 if let Some(ref buf) = playback.buffer {
30 - // Divide by 2: buffer stores interleaved stereo frames (L, R, L, R, …),
31 - // so total_frames = total_samples / channels.
32 - let total_frames = buf.data.len() / 2;
30 + // During streaming, the buffer grows so use the metadata estimate
31 + // for a stable cursor. Fall back to current buffer size otherwise.
32 + let total_frames = if playback.streaming {
33 + playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
34 + } else {
35 + buf.data.len() / 2
36 + };
33 37 if total_frames > 0 {
34 38 Some(playback.position as f32 / total_frames as f32)
35 39 } else {
@@ -56,8 +60,13 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
56 60 if state.previewing_hash.as_deref() == Some(hash) {
57 61 let mut playback = state.shared.preview.lock();
58 62 if let Some(ref buf) = playback.buffer {
59 - let total_frames = buf.data.len() / 2;
60 - playback.position = (normalized * total_frames as f32) as usize;
63 + let total_frames = if playback.streaming {
64 + playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
65 + } else {
66 + buf.data.len() / 2
67 + };
68 + playback.position = ((normalized * total_frames as f32) as usize)
69 + .min(playback.decoded_frames.max(1) - 1);
61 70 }
62 71 }
63 72 }
@@ -18,6 +18,26 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) {
18 18 _ => return,
19 19 };
20 20
21 + egui::TopBottomPanel::bottom("export_footer").show(ctx, |ui| {
22 + ui.add_space(4.0);
23 + ui.horizontal(|ui| {
24 + if ui.button("Export").clicked() {
25 + if let ImportMode::ConfigureExport { ref items, ref config, .. } =
26 + state.import_mode
27 + {
28 + let items = items.clone();
29 + let config = config.clone();
30 + state.run_export(items, config);
31 + }
32 + }
33 +
34 + if ui.button("Cancel").clicked() {
35 + state.import_mode = ImportMode::None;
36 + }
37 + });
38 + ui.add_space(2.0);
39 + });
40 +
21 41 egui::CentralPanel::default().show(ctx, |ui| {
22 42 egui::ScrollArea::vertical().show(ui, |ui| {
23 43 ui.heading("Export Samples");
@@ -244,25 +264,6 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) {
244 264 }
245 265 });
246 266 }
247 -
248 - ui.add_space(16.0);
249 -
250 - // --- Buttons ---
251 - ui.horizontal(|ui| {
252 - if ui.button("Export").clicked() {
253 - if let ImportMode::ConfigureExport { ref items, ref config, .. } =
254 - state.import_mode
255 - {
256 - let items = items.clone();
257 - let config = config.clone();
258 - state.run_export(items, config);
259 - }
260 - }
261 -
262 - if ui.button("Cancel").clicked() {
263 - state.import_mode = ImportMode::None;
264 - }
265 - });
266 267 });
267 268 });
268 269 }
@@ -393,14 +393,12 @@ fn draw_context_menu(
393 393 );
394 394 ui.separator();
395 395 }
396 - if !node.cloud_only {
397 - if ui.button("Preview").clicked() {
398 - if let Some(hash) = &node.node.sample_hash {
399 - let hash = hash.clone();
400 - state.trigger_preview(&hash);
401 - }
402 - ui.close_menu();
396 + if !node.cloud_only && ui.button("Preview").clicked() {
397 + if let Some(hash) = &node.node.sample_hash {
398 + let hash = hash.clone();
399 + state.trigger_preview(&hash);
403 400 }
401 + ui.close_menu();
404 402 }
405 403 if ui.button("Copy Path").clicked() {
406 404 if let Some(path) = state.selected_sample_path() {
@@ -423,6 +421,34 @@ fn draw_context_menu(
423 421 }
424 422 ui.close_menu();
425 423 }
424 + // Add to Collection submenu
425 + if let Some(hash) = &node.node.sample_hash {
426 + let hash_clone = hash.clone();
427 + let collections = state.collections.clone();
428 + let is_in_collection = state.active_collection.is_some();
429 + if !collections.is_empty() {
430 + ui.menu_button("Add to Collection", |ui| {
431 + for coll in &collections {
432 + if ui.button(&coll.name).clicked() {
433 + let _ = state.backend.add_to_collection(coll.id, &hash_clone);
434 + state.refresh_collections();
435 + state.status = format!("Added to {}", coll.name);
436 + ui.close_menu();
437 + }
438 + }
439 + });
440 + }
441 + if is_in_collection {
442 + if let Some(active_id) = state.active_collection {
443 + if ui.button("Remove from Collection").clicked() {
444 + let _ = state.backend.remove_from_collection(active_id, &hash_clone);
445 + state.refresh_collections();
446 + state.activate_collection(active_id);
447 + ui.close_menu();
448 + }
449 + }
450 + }
451 + }
426 452 if !node.cloud_only {
427 453 if ui.button("Play as Instrument").clicked() {
428 454 if let Some(hash) = &node.node.sample_hash {
@@ -499,6 +525,41 @@ fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
499 525 ui.close_menu();
500 526 }
501 527
528 + // Add to Collection submenu (bulk)
529 + let collections = state.collections.clone();
530 + if !collections.is_empty() {
531 + ui.menu_button("Add to Collection", |ui| {
532 + for coll in &collections {
533 + if ui.button(&coll.name).clicked() {
534 + let nodes = state.selected_nodes();
535 + for n in &nodes {
536 + if let Some(hash) = &n.node.sample_hash {
537 + let _ = state.backend.add_to_collection(coll.id, hash);
538 + }
539 + }
540 + state.refresh_collections();
541 + state.status = format!("Added {} items to {}", nodes.len(), coll.name);
542 + ui.close_menu();
543 + }
544 + }
545 + });
546 + }
547 +
548 + // Remove from Collection (when viewing a collection)
549 + if let Some(active_id) = state.active_collection {
550 + if ui.button("Remove from Collection").clicked() {
551 + let nodes = state.selected_nodes();
552 + for n in &nodes {
553 + if let Some(hash) = &n.node.sample_hash {
554 + let _ = state.backend.remove_from_collection(active_id, hash);
555 + }
556 + }
557 + state.refresh_collections();
558 + state.activate_collection(active_id);
559 + ui.close_menu();
560 + }
561 + }
562 +
502 563 ui.separator();
503 564
504 565 if ui.button("Copy Paths").clicked() {
@@ -14,6 +14,19 @@ pub fn draw_tag_folders(ctx: &egui::Context, state: &mut BrowserState) {
14 14 _ => return,
15 15 };
16 16
17 + egui::TopBottomPanel::bottom("tag_folders_footer").show(ctx, |ui| {
18 + ui.add_space(4.0);
19 + ui.horizontal(|ui| {
20 + if ui.button("Apply Tags").clicked() {
21 + state.apply_folder_tags();
22 + }
23 + if ui.button("Skip").clicked() {
24 + state.skip_folder_tags();
25 + }
26 + });
27 + ui.add_space(2.0);
28 + });
29 +
17 30 egui::CentralPanel::default().show(ctx, |ui| {
18 31 ui.heading("Tag Imported Folders");
19 32 ui.add_space(4.0);
@@ -62,16 +75,6 @@ pub fn draw_tag_folders(ctx: &egui::Context, state: &mut BrowserState) {
62 75 }
63 76 }
64 77 });
65 -
66 - ui.add_space(8.0);
67 - ui.horizontal(|ui| {
68 - if ui.button("Apply Tags").clicked() {
69 - state.apply_folder_tags();
70 - }
71 - if ui.button("Skip").clicked() {
72 - state.skip_folder_tags();
73 - }
74 - });
75 78 });
76 79 }
77 80
@@ -32,11 +32,9 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
32 32 state.vfs_rename_target = Some((vfs_id, vfs_name.clone()));
33 33 ui.close_menu();
34 34 }
35 - if vfs_count > 1 {
36 - if ui.button("Delete").clicked() {
37 - state.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs { vfs_id, vfs_name });
38 - ui.close_menu();
39 - }
35 + if vfs_count > 1 && ui.button("Delete").clicked() {
36 + state.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs { vfs_id, vfs_name });
37 + ui.close_menu();
40 38 }
41 39 });
42 40 }
@@ -82,6 +80,104 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
82 80
83 81 ui.add_space(4.0);
84 82
83 + // Collections section
84 + ui.collapsing("Collections", |ui| {
85 + if state.collections.is_empty() && !state.show_collection_create {
86 + ui.label(egui::RichText::new("No collections yet").color(theme::text_muted()));
87 + } else {
88 + let collections = state.collections.clone();
89 + let active_id = state.active_collection;
90 + let mut delete_id = None;
91 + for coll in &collections {
92 + let is_active = active_id == Some(coll.id);
93 + let label_text = format!("{} ({})", coll.name, coll.member_count);
94 + let label = if is_active {
95 + egui::RichText::new(&label_text).strong().color(theme::accent_blue())
96 + } else {
97 + egui::RichText::new(&label_text).color(theme::text_secondary())
98 + };
99 + let resp = ui.selectable_label(is_active, label)
100 + .on_hover_text(format!("Show \"{}\" contents", coll.name));
101 + if resp.clicked() {
102 + if is_active {
103 + state.deactivate_collection();
104 + } else {
105 + state.activate_collection(coll.id);
106 + }
107 + }
108 + let coll_id = coll.id;
109 + let coll_name = coll.name.clone();
110 + resp.context_menu(|ui| {
111 + if ui.button("Rename").clicked() {
112 + state.collection_rename_target = Some((coll_id, coll_name.clone()));
113 + ui.close_menu();
114 + }
115 + if ui.button("Delete").clicked() {
116 + delete_id = Some(coll_id);
117 + ui.close_menu();
118 + }
119 + });
120 + }
121 + if let Some(id) = delete_id {
122 + let _ = state.backend.delete_collection(id);
123 + if state.active_collection == Some(id) {
124 + state.deactivate_collection();
125 + }
126 + state.refresh_collections();
127 + }
128 + }
129 +
130 + // Inline rename modal
131 + if let Some((rename_id, _)) = state.collection_rename_target.clone() {
132 + ui.horizontal(|ui| {
133 + let resp = ui.text_edit_singleline(&mut state.collection_rename_target.as_mut().unwrap().1);
134 + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
135 + let new_name = state.collection_rename_target.as_ref().unwrap().1.clone();
136 + if !new_name.trim().is_empty() {
137 + let _ = state.backend.rename_collection(rename_id, new_name.trim());
138 + state.refresh_collections();
139 + }
140 + state.collection_rename_target = None;
141 + }
142 + if ui.button("Cancel").clicked() {
143 + state.collection_rename_target = None;
144 + }
145 + });
146 + }
147 +
148 + // Inline create input
149 + if state.show_collection_create {
150 + ui.horizontal(|ui| {
151 + let resp = ui.text_edit_singleline(&mut state.collection_create_input);
152 + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
153 + let name = state.collection_create_input.trim().to_string();
154 + if !name.is_empty() {
155 + match state.backend.create_collection(&name, None) {
156 + Ok(_) => {
157 + state.status = format!("Created collection: {name}");
158 + }
159 + Err(e) => {
160 + state.status = format!("Failed to create collection: {e}");
161 + }
162 + }
163 + state.refresh_collections();
164 + }
165 + state.collection_create_input.clear();
166 + state.show_collection_create = false;
167 + }
168 + if ui.button("Cancel").clicked() {
169 + state.collection_create_input.clear();
170 + state.show_collection_create = false;
171 + }
172 + });
173 + } else if ui.small_button("+").on_hover_text("Create new collection").clicked() {
174 + state.show_collection_create = true;
175 + state.collection_create_input.clear();
176 + }
177 + });
178 +
179 + ui.add_space(4.0);
180 +
85 181 // Tags section
86 182 ui.collapsing("Tags", |ui| {
87 183 if state.all_tags.is_empty() {
@@ -67,21 +67,27 @@ pub fn draw_toolbar(ui: &mut egui::Ui, state: &mut BrowserState) {
67 67 // Undo button
68 68 let undo_enabled = state.can_undo();
69 69 if ui
70 - .add_enabled(undo_enabled, egui::Button::new("\u{21B6}"))
70 + .add_enabled(undo_enabled, egui::Button::new("\u{21A9}"))
71 71 .on_hover_text("Undo (Cmd+Z)")
72 72 .clicked()
73 73 {
74 74 state.undo();
75 75 }
76 76
77 - // Panel toggles
78 - let sidebar_label = if state.sidebar_visible { "\u{25E8}" } else { "\u{25E7}" };
79 - if ui.button(sidebar_label).on_hover_text("Toggle sidebar (S)").clicked() {
77 + // Panel toggles — color conveys state: accent when active, muted when inactive
78 + let sidebar_color = if state.sidebar_visible { theme::accent_blue() } else { theme::text_muted() };
79 + if ui.button(egui::RichText::new("\u{2190}").color(sidebar_color))
80 + .on_hover_text("Toggle sidebar (S)")
81 + .clicked()
82 + {
80 83 state.sidebar_visible = !state.sidebar_visible;
81 84 }
82 85
83 - let detail_label = if state.detail_visible { "\u{25E9}" } else { "\u{25EA}" };
84 - if ui.button(detail_label).on_hover_text("Toggle detail panel (D)").clicked() {
86 + let detail_color = if state.detail_visible { theme::accent_blue() } else { theme::text_muted() };
87 + if ui.button(egui::RichText::new("\u{2192}").color(detail_color))
88 + .on_hover_text("Toggle detail panel (D)")
89 + .clicked()
90 + {
85 91 state.detail_visible = !state.detail_visible;
86 92 }
87 93
@@ -0,0 +1,256 @@
1 + //! Collections: named groups of samples for manual curation.
2 +
3 + use crate::db::Database;
4 + use crate::error::{unix_now, CoreError, Result};
5 + use crate::id_types::CollectionId;
6 + use tracing::instrument;
7 +
8 + /// A named collection of samples.
9 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10 + pub struct Collection {
11 + pub id: CollectionId,
12 + pub name: String,
13 + pub description: Option<String>,
14 + pub created_at: i64,
15 + pub member_count: usize,
16 + }
17 +
18 + /// Create a collection, returning its ID.
19 + #[instrument(skip_all)]
20 + pub fn create_collection(
21 + db: &Database,
22 + name: &str,
23 + description: Option<&str>,
24 + ) -> Result<CollectionId> {
25 + let now = unix_now();
26 + db.conn().execute(
27 + "INSERT INTO collections (name, description, created_at) VALUES (?1, ?2, ?3)",
28 + rusqlite::params![name, description, now],
29 + )?;
30 + Ok(CollectionId::from(db.conn().last_insert_rowid()))
31 + }
32 +
33 + /// List all collections with member counts, ordered by name.
34 + #[instrument(skip_all)]
35 + pub fn list_collections(db: &Database) -> Result<Vec<Collection>> {
36 + let mut stmt = db.conn().prepare(
37 + "SELECT c.id, c.name, c.description, c.created_at, COUNT(cm.sample_hash)
38 + FROM collections c
39 + LEFT JOIN collection_members cm ON cm.collection_id = c.id
40 + GROUP BY c.id
41 + ORDER BY c.name ASC",
42 + )?;
43 + let rows = stmt.query_map([], |row| {
44 + Ok(Collection {
45 + id: row.get(0)?,
46 + name: row.get(1)?,
47 + description: row.get(2)?,
48 + created_at: row.get(3)?,
49 + member_count: row.get::<_, i64>(4)? as usize,
50 + })
51 + })?;
52 + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
53 + }
54 +
55 + /// Rename a collection.
56 + #[instrument(skip_all)]
57 + pub fn rename_collection(db: &Database, id: CollectionId, new_name: &str) -> Result<()> {
58 + let changed = db.conn().execute(
59 + "UPDATE collections SET name = ?1 WHERE id = ?2",
60 + rusqlite::params![new_name, id],
61 + )?;
62 + if changed == 0 {
63 + return Err(CoreError::CollectionNotFound(id));
64 + }
65 + Ok(())
66 + }
67 +
68 + /// Delete a collection by ID. Members are cascaded by the FK constraint.
69 + #[instrument(skip_all)]
70 + pub fn delete_collection(db: &Database, id: CollectionId) -> Result<()> {
71 + let changed = db.conn().execute(
72 + "DELETE FROM collections WHERE id = ?1",
73 + rusqlite::params![id],
74 + )?;
75 + if changed == 0 {
76 + return Err(CoreError::CollectionNotFound(id));
77 + }
78 + Ok(())
79 + }
80 +
81 + /// Add a sample to a collection. No-op if already a member.
82 + #[instrument(skip_all)]
83 + pub fn add_to_collection(db: &Database, collection_id: CollectionId, sample_hash: &str) -> Result<()> {
84 + let now = unix_now();
85 + db.conn().execute(
86 + "INSERT OR IGNORE INTO collection_members (collection_id, sample_hash, added_at) VALUES (?1, ?2, ?3)",
87 + rusqlite::params![collection_id, sample_hash, now],
88 + )?;
89 + Ok(())
90 + }
91 +
92 + /// Remove a sample from a collection.
93 + #[instrument(skip_all)]
94 + pub fn remove_from_collection(db: &Database, collection_id: CollectionId, sample_hash: &str) -> Result<()> {
95 + db.conn().execute(
96 + "DELETE FROM collection_members WHERE collection_id = ?1 AND sample_hash = ?2",
97 + rusqlite::params![collection_id, sample_hash],
98 + )?;
99 + Ok(())
100 + }
101 +
102 + /// List all sample hashes in a collection.
103 + #[instrument(skip_all)]
104 + pub fn list_collection_members(db: &Database, collection_id: CollectionId) -> Result<Vec<String>> {
105 + let mut stmt = db.conn().prepare(
106 + "SELECT sample_hash FROM collection_members WHERE collection_id = ?1 ORDER BY added_at ASC",
107 + )?;
108 + let rows = stmt.query_map([collection_id], |row| row.get::<_, String>(0))?;
109 + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
110 + }
111 +
112 + /// Get all collections that contain a given sample.
113 + #[instrument(skip_all)]
114 + pub fn get_sample_collections(db: &Database, sample_hash: &str) -> Result<Vec<Collection>> {
115 + let mut stmt = db.conn().prepare(
116 + "SELECT c.id, c.name, c.description, c.created_at, COUNT(cm2.sample_hash)
117 + FROM collections c
118 + INNER JOIN collection_members cm ON cm.collection_id = c.id AND cm.sample_hash = ?1
119 + LEFT JOIN collection_members cm2 ON cm2.collection_id = c.id
120 + GROUP BY c.id
121 + ORDER BY c.name ASC",
122 + )?;
123 + let rows = stmt.query_map([sample_hash], |row| {
124 + Ok(Collection {
125 + id: row.get(0)?,
126 + name: row.get(1)?,
127 + description: row.get(2)?,
128 + created_at: row.get(3)?,
129 + member_count: row.get::<_, i64>(4)? as usize,
130 + })
131 + })?;
132 + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
133 + }
134 +
135 + #[cfg(test)]
136 + mod tests {
137 + use super::*;
138 +
139 + fn setup() -> Database {
140 + let db = Database::open_in_memory().unwrap();
141 + // Insert a fake sample for FK constraints
142 + db.conn()
143 + .execute(
144 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified)
145 + VALUES ('aaa', 'kick.wav', 'wav', 100, 0, 0)",
146 + [],
147 + )
148 + .unwrap();
149 + db.conn()
150 + .execute(
151 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified)
152 + VALUES ('bbb', 'snare.wav', 'wav', 200, 0, 0)",
153 + [],
154 + )
155 + .unwrap();
156 + db
157 + }
158 +
159 + #[test]
160 + fn create_and_list() {
161 + let db = setup();
162 + let id = create_collection(&db, "Favorites", Some("My best samples")).unwrap();
163 + assert!(id.as_i64() > 0);
164 +
165 + let collections = list_collections(&db).unwrap();
166 + assert_eq!(collections.len(), 1);
167 + assert_eq!(collections[0].name, "Favorites");
168 + assert_eq!(collections[0].description.as_deref(), Some("My best samples"));
169 + assert_eq!(collections[0].member_count, 0);
170 + }
171 +
172 + #[test]
173 + fn rename() {
174 + let db = setup();
175 + let id = create_collection(&db, "Old", None).unwrap();
176 + rename_collection(&db, id, "New").unwrap();
177 + let collections = list_collections(&db).unwrap();
178 + assert_eq!(collections[0].name, "New");
179 + }
180 +
181 + #[test]
182 + fn delete() {
183 + let db = setup();
184 + let id = create_collection(&db, "Temp", None).unwrap();
185 + delete_collection(&db, id).unwrap();
186 + assert!(list_collections(&db).unwrap().is_empty());
187 + }
188 +
189 + #[test]
190 + fn add_remove_members() {
191 + let db = setup();
192 + let id = create_collection(&db, "Test", None).unwrap();
193 +
194 + add_to_collection(&db, id, "aaa").unwrap();
195 + add_to_collection(&db, id, "bbb").unwrap();
196 +
197 + let members = list_collection_members(&db, id).unwrap();
198 + assert_eq!(members.len(), 2);
199 +
200 + // member_count reflects in list
201 + let collections = list_collections(&db).unwrap();
202 + assert_eq!(collections[0].member_count, 2);
203 +
204 + remove_from_collection(&db, id, "aaa").unwrap();
205 + let members = list_collection_members(&db, id).unwrap();
206 + assert_eq!(members.len(), 1);
207 + assert_eq!(members[0], "bbb");
208 + }
209 +
210 + #[test]
211 + fn duplicate_add_is_noop() {
212 + let db = setup();
213 + let id = create_collection(&db, "Test", None).unwrap();
214 + add_to_collection(&db, id, "aaa").unwrap();
215 + add_to_collection(&db, id, "aaa").unwrap(); // no error
216 + let members = list_collection_members(&db, id).unwrap();
217 + assert_eq!(members.len(), 1);
218 + }
219 +
220 + #[test]
221 + fn nonexistent_errors() {
222 + let db = setup();
223 + let bad_id = CollectionId::from(9999);
224 + assert!(rename_collection(&db, bad_id, "X").is_err());
225 + assert!(delete_collection(&db, bad_id).is_err());
226 + }
227 +
228 + #[test]
229 + fn get_sample_collections_works() {
230 + let db = setup();
231 + let c1 = create_collection(&db, "Alpha", None).unwrap();
232 + let c2 = create_collection(&db, "Beta", None).unwrap();
233 + add_to_collection(&db, c1, "aaa").unwrap();
234 + add_to_collection(&db, c2, "aaa").unwrap();
235 + add_to_collection(&db, c2, "bbb").unwrap();
236 +
237 + let sample_colls = get_sample_collections(&db, "aaa").unwrap();
238 + assert_eq!(sample_colls.len(), 2);
239 +
240 + let sample_colls_b = get_sample_collections(&db, "bbb").unwrap();
241 + assert_eq!(sample_colls_b.len(), 1);
242 + assert_eq!(sample_colls_b[0].name, "Beta");
243 + }
244 +
245 + #[test]
246 + fn delete_cascades_members() {
247 + let db = setup();
248 + let id = create_collection(&db, "Test", None).unwrap();
249 + add_to_collection(&db, id, "aaa").unwrap();
250 + delete_collection(&db, id).unwrap();
251 + // members table should be clean — verify by creating a new collection
252 + // with the same name and checking it has no members
253 + let id2 = create_collection(&db, "Test", None).unwrap();
254 + assert_eq!(list_collection_members(&db, id2).unwrap().len(), 0);
255 + }
256 + }
@@ -9,7 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
9 9
10 10 use thiserror::Error;
11 11
12 - use crate::id_types::{NodeId, SmartFolderId, VfsId};
12 + use crate::id_types::{CollectionId, NodeId, SmartFolderId, VfsId};
13 13
14 14 /// Unified error type for all audiofiles-core operations.
15 15 #[derive(Error, Debug)]
@@ -43,6 +43,10 @@ pub enum CoreError {
43 43 #[error("smart folder not found: {0}")]
44 44 SmartFolderNotFound(SmartFolderId),
45 45
46 + /// Referenced collection ID does not exist.
47 + #[error("collection not found: {0}")]
48 + CollectionNotFound(CollectionId),
49 +
46 50 /// A node with the same name already exists in the target directory.
47 51 #[error("name conflict: {0}")]
48 52 NameConflict(String),
@@ -174,6 +178,7 @@ mod tests {
174 178 CoreError::VfsNotFound(VfsId::from(42)),
175 179 CoreError::NodeNotFound(NodeId::from(7)),
176 180 CoreError::SmartFolderNotFound(SmartFolderId::from(1)),
181 + CoreError::CollectionNotFound(CollectionId::from(1)),
177 182 CoreError::NameConflict("dup".into()),
178 183 CoreError::TagInvalid("bad tag".into()),
179 184 CoreError::RenameInvalid("unclosed brace".into()),
@@ -62,7 +62,7 @@ macro_rules! define_i64_id {
62 62 )+};
63 63 }
64 64
65 - define_i64_id!(VfsId, NodeId, SmartFolderId);
65 + define_i64_id!(VfsId, NodeId, SmartFolderId, CollectionId);
66 66
67 67 // ── SampleHash ─────────────────────────────────────────────────
68 68
@@ -1,6 +1,7 @@
1 1 //! Core library for AudioFiles: sync-only sample storage, VFS, tags, and analysis. No UI or async.
2 2
3 3 pub mod analysis;
4 + pub mod collections;
4 5 pub mod db;
5 6 pub mod error;
6 7 pub mod export;
@@ -16,7 +17,7 @@ pub mod tags;
16 17 pub mod util;
17 18 pub mod vfs;
18 19
19 - pub use id_types::{NodeId, SampleHash, SmartFolderId, VfsId};
20 + pub use id_types::{CollectionId, NodeId, SampleHash, SmartFolderId, VfsId};
20 21
21 22 #[cfg(test)]
22 23 mod test_helpers;
@@ -28,11 +28,25 @@ pub fn fill_output(playback: &Mutex<PreviewPlayback>, buffer: &mut Buffer) {
28 28 let num_output_channels = channel_slices.len();
29 29
30 30 // Preview data is interleaved stereo (2 channels)
31 - let total_frames = preview_buf.data.len() / 2;
31 + let total_frames = if guard.streaming {
32 + guard.decoded_frames
33 + } else {
34 + preview_buf.data.len() / 2
35 + };
32 36 let mut pos = guard.position;
33 37
34 38 for sample_idx in 0..num_output_samples {
35 39 if pos >= total_frames {
40 + if guard.streaming {
41 + // Still decoding — output silence for remaining samples but keep playing
42 + for slice in channel_slices.iter_mut().take(num_output_channels) {
43 + for sample in slice.iter_mut().take(num_output_samples).skip(sample_idx) {
44 + *sample = 0.0;
45 + }
46 + }
47 + guard.position = pos;
48 + return;
49 + }
36 50 // Reached end of preview — stop and silence remaining
37 51 guard.playing = false;
38 52 guard.position = 0;
@@ -101,6 +115,9 @@ mod tests {
101 115 }),
102 116 position: 0,
103 117 playing: true,
118 + streaming: false,
119 + decoded_frames: 0,
120 + total_frames_estimate: None,
104 121 })
105 122 }
106 123
@@ -120,6 +137,9 @@ mod tests {
120 137 buffer: None,
121 138 position: 0,
122 139 playing: false,
140 + streaming: false,
141 + decoded_frames: 0,
142 + total_frames_estimate: None,
123 143 });
124 144 let mut backing = vec![vec![1.0f32; 4]; 2];
125 145 let mut buffer = make_buffer(&mut backing);
@@ -137,6 +157,9 @@ mod tests {
137 157 buffer: None,
138 158 position: 0,
139 159 playing: true,
160 + streaming: false,
161 + decoded_frames: 0,
162 + total_frames_estimate: None,
140 163 });
141 164 let mut backing = vec![vec![1.0f32; 4]; 2];
142 165 let mut buffer = make_buffer(&mut backing);