max / audiofiles
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); |