| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
|
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
|
| 90 |
|
| 91 |
|
| 92 |
|
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
|
| 101 |
use quasi_router::layout::{Priority, Sort, Tone, Width}; |
| 102 |
use quasi_router::{ |
| 103 |
Act, Action, Cell, Cells, Choice, Column, Field, Node, RegionKind, Request, Response, |
| 104 |
RouteError, Router, Screen, Slot, Tag, |
| 105 |
}; |
| 106 |
|
| 107 |
use super::{Collection, Panels, Sample}; |
| 108 |
|
| 109 |
|
| 110 |
const BODY: &str = "files-body"; |
| 111 |
|
| 112 |
|
| 113 |
const NAME: &str = "Name"; |
| 114 |
const DUR: &str = "Duration"; |
| 115 |
const BPM: &str = "BPM"; |
| 116 |
const KEY: &str = "Key"; |
| 117 |
const PEAK: &str = "Peak dB"; |
| 118 |
const TAGS: &str = "Tags"; |
| 119 |
const PLAY: &str = "Play"; |
| 120 |
|
| 121 |
|
| 122 |
const COLLECTION: &str = "collection"; |
| 123 |
|
| 124 |
|
| 125 |
pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { |
| 126 |
router |
| 127 |
.get("/files", index) |
| 128 |
.post("/files/{id}/open", open) |
| 129 |
.post("/files/{id}/play", play) |
| 130 |
.post("/files/sort/{column}", sort) |
| 131 |
|
| 132 |
|
| 133 |
|
| 134 |
|
| 135 |
.post("/files/{id}/enter", enter) |
| 136 |
.post("/files/{id}/path/copy", copy_path) |
| 137 |
.post("/files/{id}/reveal", reveal) |
| 138 |
.post("/files/{id}/similar", find_similar) |
| 139 |
.post("/files/{id}/duplicates", find_duplicates) |
| 140 |
.post("/files/{id}/edit", edit) |
| 141 |
.post("/files/{id}/instrument", instrument) |
| 142 |
.post("/files/{id}/export", export) |
| 143 |
.post("/files/{id}/reanalyze", reanalyze) |
| 144 |
.post("/files/{id}/delete", delete) |
| 145 |
.post("/files/{id}/download", download) |
| 146 |
.post("/files/{id}/collection/remove", remove_from_collection) |
| 147 |
.post("/files/{id}/collection/add", add_to_collection) |
| 148 |
} |
| 149 |
|
| 150 |
|
| 151 |
fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { |
| 152 |
Ok(screen(state).into()) |
| 153 |
} |
| 154 |
|
| 155 |
|
| 156 |
fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 157 |
let id = id_of(&request)?; |
| 158 |
state.files.open(id); |
| 159 |
Ok(screen(state).into()) |
| 160 |
} |
| 161 |
|
| 162 |
|
| 163 |
fn play(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 164 |
let id = id_of(&request)?; |
| 165 |
state.files.play(id); |
| 166 |
Ok(screen(state).into()) |
| 167 |
} |
| 168 |
|
| 169 |
|
| 170 |
|
| 171 |
|
| 172 |
|
| 173 |
fn enter(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 174 |
let id = id_of(&request)?; |
| 175 |
state.files.enter(id); |
| 176 |
Ok(screen(state).into()) |
| 177 |
} |
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
|
| 183 |
|
| 184 |
|
| 185 |
fn copy_path(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 186 |
let id = id_of(&request)?; |
| 187 |
state.files.open(id); |
| 188 |
state.detail.copy_path(); |
| 189 |
Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied.")) |
| 190 |
} |
| 191 |
|
| 192 |
|
| 193 |
fn reveal(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 194 |
let id = id_of(&request)?; |
| 195 |
state.files.reveal(id); |
| 196 |
Ok(screen(state).into()) |
| 197 |
} |
| 198 |
|
| 199 |
|
| 200 |
fn find_similar(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 201 |
let id = id_of(&request)?; |
| 202 |
state.files.open(id); |
| 203 |
state.detail.find_similar(); |
| 204 |
Ok(screen(state).into()) |
| 205 |
} |
| 206 |
|
| 207 |
|
| 208 |
fn find_duplicates(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 209 |
let id = id_of(&request)?; |
| 210 |
state.files.open(id); |
| 211 |
state.detail.find_duplicates(); |
| 212 |
Ok(screen(state).into()) |
| 213 |
} |
| 214 |
|
| 215 |
|
| 216 |
fn edit(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 217 |
let id = id_of(&request)?; |
| 218 |
state.files.open(id); |
| 219 |
state.detail.edit(); |
| 220 |
Ok(screen(state).into()) |
| 221 |
} |
| 222 |
|
| 223 |
|
| 224 |
fn instrument(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 225 |
let id = id_of(&request)?; |
| 226 |
state.files.as_instrument(id); |
| 227 |
Ok(screen(state).into()) |
| 228 |
} |
| 229 |
|
| 230 |
|
| 231 |
|
| 232 |
|
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
fn export(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 237 |
let id = id_of(&request)?; |
| 238 |
state.files.open(id); |
| 239 |
state.export.open(); |
| 240 |
Ok(Response::from(quasi_router::Outcome::Goto(Action::get( |
| 241 |
"/export", |
| 242 |
)))) |
| 243 |
} |
| 244 |
|
| 245 |
|
| 246 |
fn reanalyze(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 247 |
let id = id_of(&request)?; |
| 248 |
state.files.reanalyze(id); |
| 249 |
Ok(screen(state).into()) |
| 250 |
} |
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
|
| 256 |
|
| 257 |
|
| 258 |
fn delete(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 259 |
let id = id_of(&request)?; |
| 260 |
state.files.delete(id); |
| 261 |
Ok(screen(state).into()) |
| 262 |
} |
| 263 |
|
| 264 |
|
| 265 |
fn download(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 266 |
let id = id_of(&request)?; |
| 267 |
state.files.download(id); |
| 268 |
Ok(screen(state).into()) |
| 269 |
} |
| 270 |
|
| 271 |
|
| 272 |
fn remove_from_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 273 |
let id = id_of(&request)?; |
| 274 |
state.files.remove_from_collection(id); |
| 275 |
Ok(screen(state).into()) |
| 276 |
} |
| 277 |
|
| 278 |
|
| 279 |
|
| 280 |
|
| 281 |
|
| 282 |
|
| 283 |
|
| 284 |
|
| 285 |
fn add_to_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 286 |
let id = id_of(&request)?; |
| 287 |
let chosen = request |
| 288 |
.payload |
| 289 |
.get(COLLECTION) |
| 290 |
.and_then(|value| value.parse::<i64>().ok()) |
| 291 |
.ok_or_else(|| RouteError::not_found("no collection named"))?; |
| 292 |
let named = state |
| 293 |
.library |
| 294 |
.collections() |
| 295 |
.into_iter() |
| 296 |
.find(|collection| collection.id == chosen) |
| 297 |
.ok_or_else(|| RouteError::not_found("no such collection"))?; |
| 298 |
state.files.add_to_collection(id, named.id); |
| 299 |
Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name))) |
| 300 |
} |
| 301 |
|
| 302 |
|
| 303 |
|
| 304 |
|
| 305 |
|
| 306 |
|
| 307 |
fn sort(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { |
| 308 |
let column = request.captures.require("column")?; |
| 309 |
if !sortable(column) { |
| 310 |
return Err(RouteError::not_found("no such sort")); |
| 311 |
} |
| 312 |
state.files.sort_by(column); |
| 313 |
Ok(screen(state).into()) |
| 314 |
} |
| 315 |
|
| 316 |
|
| 317 |
fn id_of(request: &Request) -> Result<i64, RouteError> { |
| 318 |
request |
| 319 |
.captures |
| 320 |
.require("id")? |
| 321 |
.parse() |
| 322 |
.map_err(|_| RouteError::not_found("no such sample")) |
| 323 |
} |
| 324 |
|
| 325 |
|
| 326 |
|
| 327 |
|
| 328 |
|
| 329 |
|
| 330 |
fn sortable(column: &str) -> bool { |
| 331 |
matches!(column, NAME | DUR | BPM | KEY) |
| 332 |
} |
| 333 |
|
| 334 |
|
| 335 |
fn screen(state: &Panels<'_>) -> Screen { |
| 336 |
Screen::sidebar_content("Samples").with(body(state)) |
| 337 |
} |
| 338 |
|
| 339 |
|
| 340 |
|
| 341 |
|
| 342 |
|
| 343 |
|
| 344 |
|
| 345 |
pub fn body(state: &Panels<'_>) -> Slot { |
| 346 |
let shown = state.files.columns(); |
| 347 |
let samples = state.files.samples(); |
| 348 |
let current = state.files.current(); |
| 349 |
|
| 350 |
|
| 351 |
|
| 352 |
|
| 353 |
let collections = state.library.collections(); |
| 354 |
let collection = collections.iter().any(|collection| collection.active); |
| 355 |
|
| 356 |
if samples.is_empty() { |
| 357 |
|
| 358 |
|
| 359 |
|
| 360 |
Slot::new(BODY, RegionKind::Pane).with( |
| 361 |
Node::empty("Nothing here yet.") |
| 362 |
.offering(Act::new("Import samples", Action::get("/import/open"))), |
| 363 |
) |
| 364 |
} else { |
| 365 |
Slot::new(BODY, RegionKind::Pane).with(Node::Table { |
| 366 |
columns: columns(state, shown), |
| 367 |
rows: samples |
| 368 |
.iter() |
| 369 |
.map(|sample| row(sample, shown, current, collection, &collections)) |
| 370 |
.collect(), |
| 371 |
|
| 372 |
|
| 373 |
|
| 374 |
more: None, |
| 375 |
}) |
| 376 |
} |
| 377 |
} |
| 378 |
|
| 379 |
|
| 380 |
|
| 381 |
|
| 382 |
|
| 383 |
|
| 384 |
|
| 385 |
fn columns(state: &Panels<'_>, shown: super::ColumnsShown) -> Vec<Column> { |
| 386 |
let (by, ascending) = state.files.sort(); |
| 387 |
let sorted_by = |name: &str| { |
| 388 |
(name == by).then_some(if ascending { |
| 389 |
Sort::Ascending |
| 390 |
} else { |
| 391 |
Sort::Descending |
| 392 |
}) |
| 393 |
}; |
| 394 |
let data = |name: &'static str, priority: Priority| { |
| 395 |
let mut column = Column::new(name) |
| 396 |
.width(if name == NAME { |
| 397 |
Width::Fill |
| 398 |
} else { |
| 399 |
Width::Fixed |
| 400 |
}) |
| 401 |
.priority(priority); |
| 402 |
column.sorted = sorted_by(name); |
| 403 |
if sortable(name) { |
| 404 |
column = column.reorder(Action::post(format!("/files/sort/{name}"))); |
| 405 |
} |
| 406 |
column |
| 407 |
}; |
| 408 |
|
| 409 |
let mut columns = vec![data(NAME, Priority::Essential)]; |
| 410 |
if shown.duration { |
| 411 |
columns.push(data(DUR, Priority::Secondary)); |
| 412 |
} |
| 413 |
if shown.bpm { |
| 414 |
columns.push(data(BPM, Priority::Secondary)); |
| 415 |
} |
| 416 |
if shown.key { |
| 417 |
columns.push(data(KEY, Priority::Secondary)); |
| 418 |
} |
| 419 |
if shown.peak_db { |
| 420 |
columns.push(data(PEAK, Priority::Optional)); |
| 421 |
} |
| 422 |
if shown.tags { |
| 423 |
columns.push(data(TAGS, Priority::Optional)); |
| 424 |
} |
| 425 |
|
| 426 |
|
| 427 |
columns.push(data(PLAY, Priority::Essential)); |
| 428 |
columns |
| 429 |
} |
| 430 |
|
| 431 |
|
| 432 |
fn row( |
| 433 |
sample: &Sample, |
| 434 |
shown: super::ColumnsShown, |
| 435 |
current: Option<i64>, |
| 436 |
collection: bool, |
| 437 |
collections: &[Collection], |
| 438 |
) -> Cells { |
| 439 |
let mut values = vec![Cell::new(&sample.name)]; |
| 440 |
if shown.duration { |
| 441 |
values.push(Cell::new(seconds(sample.duration))); |
| 442 |
} |
| 443 |
if shown.bpm { |
| 444 |
values.push(Cell::new( |
| 445 |
sample |
| 446 |
.bpm |
| 447 |
.map_or_else(String::new, |bpm| format!("{bpm:.0}")), |
| 448 |
)); |
| 449 |
} |
| 450 |
if shown.key { |
| 451 |
values.push(Cell::new(sample.key.clone().unwrap_or_default())); |
| 452 |
} |
| 453 |
if shown.peak_db { |
| 454 |
values.push(Cell::new( |
| 455 |
sample |
| 456 |
.peak_db |
| 457 |
.map_or_else(String::new, |db| format!("{db:.1}")), |
| 458 |
)); |
| 459 |
} |
| 460 |
if shown.tags { |
| 461 |
|
| 462 |
|
| 463 |
|
| 464 |
|
| 465 |
|
| 466 |
|
| 467 |
|
| 468 |
let mut cell = match sample.tags.first() { |
| 469 |
Some(first) => Cell::tag(Tag::badge(first.clone())), |
| 470 |
None => Cell::new(""), |
| 471 |
}; |
| 472 |
for tag in sample.tags.iter().skip(1) { |
| 473 |
cell = cell.token(Tag::badge(tag.clone())); |
| 474 |
} |
| 475 |
values.push(cell); |
| 476 |
} |
| 477 |
|
| 478 |
|
| 479 |
|
| 480 |
|
| 481 |
values.push(if sample.directory || sample.cloud_only { |
| 482 |
Cell::new("") |
| 483 |
} else { |
| 484 |
Cell::acts([Act::new( |
| 485 |
"Play", |
| 486 |
Action::post(format!("/files/{}/play", sample.id)), |
| 487 |
)]) |
| 488 |
}); |
| 489 |
|
| 490 |
let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id))); |
| 491 |
row.current = current == Some(sample.id); |
| 492 |
row.menu = menu(sample, collection, collections); |
| 493 |
row |
| 494 |
} |
| 495 |
|
| 496 |
|
| 497 |
|
| 498 |
|
| 499 |
|
| 500 |
|
| 501 |
|
| 502 |
|
| 503 |
|
| 504 |
|
| 505 |
|
| 506 |
|
| 507 |
|
| 508 |
|
| 509 |
|
| 510 |
|
| 511 |
|
| 512 |
|
| 513 |
|
| 514 |
|
| 515 |
|
| 516 |
|
| 517 |
|
| 518 |
|
| 519 |
|
| 520 |
|
| 521 |
|
| 522 |
|
| 523 |
|
| 524 |
|
| 525 |
|
| 526 |
fn menu(sample: &Sample, collection: bool, collections: &[Collection]) -> Vec<Act> { |
| 527 |
let id = sample.id; |
| 528 |
let at = |verb: &str| Action::post(format!("/files/{id}/{verb}")); |
| 529 |
|
| 530 |
if sample.directory { |
| 531 |
return vec![ |
| 532 |
Act::new("Open", at("enter")), |
| 533 |
|
| 534 |
|
| 535 |
|
| 536 |
Act::new("New Folder", Action::get("/folders/new")), |
| 537 |
Act::new("Rename", Action::get(format!("/folders/{id}/rename"))), |
| 538 |
Act::new("Export...", at("export")), |
| 539 |
Act::new("Delete", at("delete")) |
| 540 |
.tone(Tone::Danger) |
| 541 |
.confirm(format!("Delete {}?", sample.name)), |
| 542 |
]; |
| 543 |
} |
| 544 |
|
| 545 |
let mut acts = Vec::new(); |
| 546 |
|
| 547 |
|
| 548 |
|
| 549 |
if sample.cloud_only { |
| 550 |
acts.push(Act::new("Download", at("download"))); |
| 551 |
} else { |
| 552 |
acts.push(Act::new("Preview", at("play"))); |
| 553 |
} |
| 554 |
|
| 555 |
acts.push(Act::new("Copy Path", at("path/copy"))); |
| 556 |
|
| 557 |
if !sample.cloud_only { |
| 558 |
acts.push(Act::new( |
| 559 |
crate::ui::file_list_menus::reveal_label(), |
| 560 |
at("reveal"), |
| 561 |
)); |
| 562 |
} |
| 563 |
|
| 564 |
|
| 565 |
|
| 566 |
acts.push(Act::new("Find Similar", at("similar")).key("shift+f")); |
| 567 |
acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d")); |
| 568 |
|
| 569 |
|
| 570 |
|
| 571 |
|
| 572 |
|
| 573 |
if !collections.is_empty() { |
| 574 |
acts.push( |
| 575 |
Act::new("Add to Collection", at("collection/add")).asking(Field::select( |
| 576 |
COLLECTION, |
| 577 |
"Collection", |
| 578 |
collections |
| 579 |
.iter() |
| 580 |
.map(|it| Choice::new(it.id.to_string(), it.name.clone())) |
| 581 |
.collect(), |
| 582 |
)), |
| 583 |
); |
| 584 |
} |
| 585 |
|
| 586 |
if collection { |
| 587 |
acts.push(Act::new("Remove from Collection", at("collection/remove")).tone(Tone::Danger)); |
| 588 |
} |
| 589 |
|
| 590 |
if !sample.cloud_only { |
| 591 |
acts.push(Act::new("Edit...", at("edit")).key("e")); |
| 592 |
acts.push(Act::new("Play as Instrument", at("instrument"))); |
| 593 |
acts.push(Act::new("Export...", at("export"))); |
| 594 |
acts.push(Act::new("Re-analyze...", at("reanalyze"))); |
| 595 |
} |
| 596 |
|
| 597 |
acts.push( |
| 598 |
Act::new("Delete", at("delete")) |
| 599 |
.tone(Tone::Danger) |
| 600 |
.confirm(format!("Delete {}?", sample.name)), |
| 601 |
); |
| 602 |
acts |
| 603 |
} |
| 604 |
|
| 605 |
|
| 606 |
fn seconds(duration: Option<f64>) -> String { |
| 607 |
let Some(seconds) = duration else { |
| 608 |
return String::new(); |
| 609 |
}; |
| 610 |
if seconds < 60.0 { |
| 611 |
format!("{seconds:.1}s") |
| 612 |
} else { |
| 613 |
#[expect( |
| 614 |
clippy::cast_possible_truncation, |
| 615 |
clippy::cast_sign_loss, |
| 616 |
reason = "a sample's length in minutes is small and positive" |
| 617 |
)] |
| 618 |
let minutes = (seconds / 60.0) as u32; |
| 619 |
#[expect( |
| 620 |
clippy::cast_possible_truncation, |
| 621 |
clippy::cast_sign_loss, |
| 622 |
reason = "the remainder is under sixty" |
| 623 |
)] |
| 624 |
let rest = (seconds % 60.0) as u32; |
| 625 |
format!("{minutes}:{rest:02}") |
| 626 |
} |
| 627 |
} |
| 628 |
|