Migrate Fbrowser to Rust and Tauri desktop app
- Replace the Python/Docker setup with a Rust workspace and Tauri frontend - Add core crates for archive, audio, MIDI, plugin, and desktop UI layers - Refresh the app scaffolding, build config, and documentation
This commit is contained in:
parent
bec620e2c6
commit
565be4e1e7
@ -1,27 +0,0 @@
|
|||||||
**/__pycache__
|
|
||||||
**/.venv
|
|
||||||
**/.classpath
|
|
||||||
**/.dockerignore
|
|
||||||
**/.env
|
|
||||||
**/.git
|
|
||||||
**/.gitignore
|
|
||||||
**/.project
|
|
||||||
**/.settings
|
|
||||||
**/.toolstarget
|
|
||||||
**/.vs
|
|
||||||
**/.vscode
|
|
||||||
**/*.*proj.user
|
|
||||||
**/*.dbmdl
|
|
||||||
**/*.jfm
|
|
||||||
**/bin
|
|
||||||
**/charts
|
|
||||||
**/docker-compose*
|
|
||||||
**/compose*
|
|
||||||
**/Dockerfile*
|
|
||||||
**/node_modules
|
|
||||||
**/npm-debug.log
|
|
||||||
**/obj
|
|
||||||
**/secrets.dev.yaml
|
|
||||||
**/values.dev.yaml
|
|
||||||
LICENSE
|
|
||||||
README.md
|
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
target/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
Fbrowser.7z
|
||||||
|
baseline_zip.zip
|
||||||
|
python-src/Dockerfile
|
||||||
|
python-src/docker-compose.yml
|
||||||
|
python-src/docker-compose.debug.yml
|
||||||
|
migrations/0001_init.sql
|
||||||
|
__pycache__/
|
||||||
|
test.png
|
||||||
|
paq9a.cpp
|
||||||
|
src-paq-next/output.paqg++
|
||||||
|
output_v2.paq
|
||||||
|
output.paq
|
||||||
|
pypaqtest.paq
|
||||||
|
src-paq-next/
|
||||||
19
.vscode/launch.json
vendored
19
.vscode/launch.json
vendored
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Docker: Python - General",
|
|
||||||
"type": "docker",
|
|
||||||
"request": "launch",
|
|
||||||
"preLaunchTask": "docker-run: debug",
|
|
||||||
"python": {
|
|
||||||
"pathMappings": [
|
|
||||||
{
|
|
||||||
"localRoot": "${workspaceFolder}",
|
|
||||||
"remoteRoot": "/app"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"projectType": "general"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"github.gitAuthentication": false
|
|
||||||
}
|
|
||||||
26
.vscode/tasks.json
vendored
26
.vscode/tasks.json
vendored
@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"type": "docker-build",
|
|
||||||
"label": "docker-build",
|
|
||||||
"platform": "python",
|
|
||||||
"dockerBuild": {
|
|
||||||
"tag": "fbrowser:latest",
|
|
||||||
"dockerfile": "${workspaceFolder}/Dockerfile",
|
|
||||||
"context": "${workspaceFolder}",
|
|
||||||
"pull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "docker-run",
|
|
||||||
"label": "docker-run: debug",
|
|
||||||
"dependsOn": [
|
|
||||||
"docker-build"
|
|
||||||
],
|
|
||||||
"python": {
|
|
||||||
"file": "Fbrowser.py"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
6739
Cargo.lock
generated
Normal file
6739
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/fbrowser-core",
|
||||||
|
"crates/fbrowser-audio",
|
||||||
|
"crates/fbrowser-midi",
|
||||||
|
"crates/fbrowser-archive",
|
||||||
|
"crates/fbrowser-plugin-core",
|
||||||
|
"src-tauri"
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1.0.98"
|
||||||
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
|
flate2 = "1.1.1"
|
||||||
|
ignore = "0.4.23"
|
||||||
|
lofty = "0.22.4"
|
||||||
|
midir = "0.10.1"
|
||||||
|
midly = "0.5.3"
|
||||||
|
rayon = "1.10.0"
|
||||||
|
rodio = { version = "0.22.2", default-features = true }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
serde_json = "1.0.140"
|
||||||
|
sqlx = { version = "0.8.4", features = ["sqlite", "runtime-tokio-rustls", "chrono"] }
|
||||||
|
symphonia = { version = "0.5.4", features = ["aac", "aiff", "alac", "flac", "isomp4", "mp3", "ogg", "pcm", "vorbis", "wav"] }
|
||||||
|
tar = "0.4.44"
|
||||||
|
tauri = { version = "2.5.1", features = [] }
|
||||||
|
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||||
|
uuid = { version = "1.17.0", features = ["serde", "v4"] }
|
||||||
|
walkdir = "2.5.0"
|
||||||
|
zip = { version = "2.4.1", default-features = false, features = ["deflate"] }
|
||||||
23
Dockerfile
23
Dockerfile
@ -1,23 +0,0 @@
|
|||||||
# For more information, please refer to https://aka.ms/vscode-docker-python
|
|
||||||
FROM python:3-slim
|
|
||||||
|
|
||||||
# Keeps Python from generating .pyc files in the container
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
|
|
||||||
# Turns off buffering for easier container logging
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
# Install pip requirements
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN python -m pip install -r requirements.txt
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
# Creates a non-root user with an explicit UID and adds permission to access the /app folder
|
|
||||||
# For more info, please refer to https://aka.ms/vscode-docker-python-configure-containers
|
|
||||||
RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
# During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug
|
|
||||||
CMD ["python", "Fbrowser.py"]
|
|
||||||
BIN
Fbroswer.tar.gz
BIN
Fbroswer.tar.gz
Binary file not shown.
BIN
Fbrowser.zip
BIN
Fbrowser.zip
Binary file not shown.
28
README.md
Normal file
28
README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Fbrowser
|
||||||
|
|
||||||
|
Fbrowser is being migrated from a Python/PyQt desktop app to a Rust + Tauri + React desktop application.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
- `src/`: React frontend for the new desktop app.
|
||||||
|
- `src-tauri/`: Tauri host application and IPC command layer.
|
||||||
|
- `crates/`: Shared Rust workspace crates for catalog, audio, MIDI, archives, and future plugin-facing APIs.
|
||||||
|
- `migrations/`: SQLite schema migrations for the new local catalog.
|
||||||
|
- `python-src/`: Legacy Python implementation kept as a feature reference during migration.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- The new Tauri/Rust/React application builds successfully.
|
||||||
|
- The legacy Python code has been moved out of the repository root to keep the migration boundary clear.
|
||||||
|
- The desktop shell, catalog model, scan pipeline, archive utilities, timer, waveform generation, and transport scaffolding are in place.
|
||||||
|
- Some areas are still scaffold-level rather than production-complete, especially MIDI playback internals and broader archive-format support.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Frontend production build: `npm run build`
|
||||||
|
- Rust workspace checks/tests: `cargo check --manifest-path src-tauri/Cargo.toml` and `cargo test --workspace`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Python code under `python-src/` is no longer the primary application entrypoint.
|
||||||
|
- The new root-level app is the active migration target.
|
||||||
Binary file not shown.
Binary file not shown.
13
crates/fbrowser-archive/Cargo.toml
Normal file
13
crates/fbrowser-archive/Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "fbrowser-archive"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
flate2.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
tar.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
345
crates/fbrowser-archive/src/lib.rs
Normal file
345
crates/fbrowser-archive/src/lib.rs
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tar::{Archive as TarArchive, Builder as TarBuilder};
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ArchiveJobSpec {
|
||||||
|
pub source: String,
|
||||||
|
pub destination: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ArchiveJobResult {
|
||||||
|
pub output_path: String,
|
||||||
|
pub processed_entries: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ArchiveFormat {
|
||||||
|
Zip,
|
||||||
|
Tar,
|
||||||
|
TarGz,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract(spec: &ArchiveJobSpec) -> Result<ArchiveJobResult> {
|
||||||
|
let source = Path::new(&spec.source);
|
||||||
|
let destination = Path::new(&spec.destination);
|
||||||
|
fs::create_dir_all(destination)?;
|
||||||
|
|
||||||
|
match detect_archive_format(source) {
|
||||||
|
Some(ArchiveFormat::Zip) => extract_zip(source, destination),
|
||||||
|
Some(ArchiveFormat::Tar) => extract_tar(File::open(source)?, destination),
|
||||||
|
Some(ArchiveFormat::TarGz) => extract_tar(GzDecoder::new(File::open(source)?), destination),
|
||||||
|
None => bail!(
|
||||||
|
"unsupported archive format for extract: {} (supported: .zip, .tar, .tar.gz, .tgz)",
|
||||||
|
source.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compress(spec: &ArchiveJobSpec) -> Result<ArchiveJobResult> {
|
||||||
|
let source = Path::new(&spec.source);
|
||||||
|
let destination = Path::new(&spec.destination);
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match detect_archive_format(destination) {
|
||||||
|
Some(ArchiveFormat::Zip) => compress_zip(source, destination),
|
||||||
|
Some(ArchiveFormat::Tar) => compress_tar(source, destination, false),
|
||||||
|
Some(ArchiveFormat::TarGz) => compress_tar(source, destination, true),
|
||||||
|
None => bail!(
|
||||||
|
"unsupported archive format for compress: {} (supported: .zip, .tar, .tar.gz, .tgz)",
|
||||||
|
destination.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_archive_format(path: &Path) -> Option<ArchiveFormat> {
|
||||||
|
let lower_name = path.file_name()?.to_str()?.to_ascii_lowercase();
|
||||||
|
if lower_name.ends_with(".tar.gz") || lower_name.ends_with(".tgz") {
|
||||||
|
Some(ArchiveFormat::TarGz)
|
||||||
|
} else if lower_name.ends_with(".tar") {
|
||||||
|
Some(ArchiveFormat::Tar)
|
||||||
|
} else if lower_name.ends_with(".zip") {
|
||||||
|
Some(ArchiveFormat::Zip)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress_zip(source: &Path, destination: &Path) -> Result<ArchiveJobResult> {
|
||||||
|
let file = File::create(destination)?;
|
||||||
|
let mut zip = ZipWriter::new(file);
|
||||||
|
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||||
|
let mut processed = 0_usize;
|
||||||
|
|
||||||
|
if source.is_file() {
|
||||||
|
let name = source
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("item")
|
||||||
|
.to_string();
|
||||||
|
add_file_to_zip(&mut zip, source, &name, options)?;
|
||||||
|
processed += 1;
|
||||||
|
} else {
|
||||||
|
for entry in walk(source)? {
|
||||||
|
let relative = entry.strip_prefix(source)?.to_string_lossy().replace('\\', "/");
|
||||||
|
add_file_to_zip(&mut zip, &entry, &relative, options)?;
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.finish()?;
|
||||||
|
Ok(ArchiveJobResult {
|
||||||
|
output_path: destination.display().to_string(),
|
||||||
|
processed_entries: processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress_tar(source: &Path, destination: &Path, gzip: bool) -> Result<ArchiveJobResult> {
|
||||||
|
let mut processed = 0_usize;
|
||||||
|
|
||||||
|
if gzip {
|
||||||
|
let file = File::create(destination)?;
|
||||||
|
let writer = GzEncoder::new(file, Compression::default());
|
||||||
|
let mut builder = TarBuilder::new(writer);
|
||||||
|
append_to_tar(&mut builder, source, &mut processed)?;
|
||||||
|
builder.finish()?;
|
||||||
|
} else {
|
||||||
|
let file = File::create(destination)?;
|
||||||
|
let mut builder = TarBuilder::new(file);
|
||||||
|
append_to_tar(&mut builder, source, &mut processed)?;
|
||||||
|
builder.finish()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ArchiveJobResult {
|
||||||
|
output_path: destination.display().to_string(),
|
||||||
|
processed_entries: processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_zip(source: &Path, destination: &Path) -> Result<ArchiveJobResult> {
|
||||||
|
let file = File::open(source)?;
|
||||||
|
let mut archive = ZipArchive::new(file)?;
|
||||||
|
let mut processed = 0_usize;
|
||||||
|
|
||||||
|
for index in 0..archive.len() {
|
||||||
|
let mut entry = archive.by_index(index)?;
|
||||||
|
let Some(relative_path) = entry.enclosed_name().map(|path| path.to_owned()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let out_path = destination.join(relative_path);
|
||||||
|
if entry.is_dir() {
|
||||||
|
fs::create_dir_all(&out_path)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut out = File::create(&out_path)?;
|
||||||
|
io::copy(&mut entry, &mut out)?;
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ArchiveJobResult {
|
||||||
|
output_path: destination.display().to_string(),
|
||||||
|
processed_entries: processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar<R: Read>(reader: R, destination: &Path) -> Result<ArchiveJobResult> {
|
||||||
|
let mut archive = TarArchive::new(reader);
|
||||||
|
let mut processed = 0_usize;
|
||||||
|
|
||||||
|
for entry in archive.entries()? {
|
||||||
|
let mut entry = entry?;
|
||||||
|
if entry.header().entry_type().is_dir() {
|
||||||
|
entry.unpack_in(destination)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.unpack_in(destination)?;
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ArchiveJobResult {
|
||||||
|
output_path: destination.display().to_string(),
|
||||||
|
processed_entries: processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_file_to_zip(
|
||||||
|
zip: &mut ZipWriter<File>,
|
||||||
|
source: &Path,
|
||||||
|
archive_path: &str,
|
||||||
|
options: SimpleFileOptions,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut file = File::open(source)?;
|
||||||
|
zip.start_file(archive_path, options)?;
|
||||||
|
io::copy(&mut file, zip)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_to_tar<W: io::Write>(
|
||||||
|
builder: &mut TarBuilder<W>,
|
||||||
|
source: &Path,
|
||||||
|
processed: &mut usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
if source.is_file() {
|
||||||
|
let name = source
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("item")
|
||||||
|
.to_string();
|
||||||
|
builder.append_path_with_name(source, name)?;
|
||||||
|
*processed += 1;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root_name = source
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("archive-root")
|
||||||
|
.to_string();
|
||||||
|
for entry in walk(source)? {
|
||||||
|
let relative = entry.strip_prefix(source)?.to_path_buf();
|
||||||
|
let archive_path = Path::new(&root_name).join(relative);
|
||||||
|
builder.append_path_with_name(&entry, archive_path)?;
|
||||||
|
*processed += 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(root: &Path) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for entry in fs::read_dir(root)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
files.extend(walk(&path)?);
|
||||||
|
} else if path.is_file() {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{compress, extract, ArchiveJobSpec};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temp_path(name: &str) -> PathBuf {
|
||||||
|
let suffix = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("time went backwards")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("fbrowser-{name}-{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zip_roundtrip_preserves_nested_file_contents() {
|
||||||
|
let source_dir = temp_path("archive-source");
|
||||||
|
let nested_dir = source_dir.join("nested");
|
||||||
|
let archive_path = temp_path("archive-output").with_extension("zip");
|
||||||
|
let extract_dir = temp_path("archive-extract");
|
||||||
|
|
||||||
|
fs::create_dir_all(&nested_dir).expect("create source dir");
|
||||||
|
fs::write(source_dir.join("root.txt"), "root-data").expect("write root file");
|
||||||
|
fs::write(nested_dir.join("child.txt"), "nested-data").expect("write nested file");
|
||||||
|
|
||||||
|
let compress_result = compress(&ArchiveJobSpec {
|
||||||
|
source: source_dir.display().to_string(),
|
||||||
|
destination: archive_path.display().to_string(),
|
||||||
|
})
|
||||||
|
.expect("compress directory");
|
||||||
|
assert_eq!(compress_result.processed_entries, 2);
|
||||||
|
assert!(archive_path.exists());
|
||||||
|
|
||||||
|
let extract_result = extract(&ArchiveJobSpec {
|
||||||
|
source: archive_path.display().to_string(),
|
||||||
|
destination: extract_dir.display().to_string(),
|
||||||
|
})
|
||||||
|
.expect("extract archive");
|
||||||
|
assert_eq!(extract_result.processed_entries, 2);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(extract_dir.join("root.txt")).expect("read extracted root file"),
|
||||||
|
"root-data"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(extract_dir.join("nested").join("child.txt")).expect("read extracted nested file"),
|
||||||
|
"nested-data"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&source_dir);
|
||||||
|
let _ = fs::remove_file(&archive_path);
|
||||||
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_rejects_unsupported_archive_types() {
|
||||||
|
let source_path = temp_path("archive-unsupported").with_extension("rar");
|
||||||
|
let destination = temp_path("archive-unsupported-out");
|
||||||
|
fs::write(&source_path, b"not-a-real-rar").expect("write unsupported source");
|
||||||
|
|
||||||
|
let error = extract(&ArchiveJobSpec {
|
||||||
|
source: source_path.display().to_string(),
|
||||||
|
destination: destination.display().to_string(),
|
||||||
|
})
|
||||||
|
.expect_err("unsupported format should fail");
|
||||||
|
assert!(error.to_string().contains("unsupported archive format"));
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&source_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tar_gz_roundtrip_preserves_directory_structure() {
|
||||||
|
let source_dir = temp_path("archive-source-targz");
|
||||||
|
let nested_dir = source_dir.join("drums").join("kicks");
|
||||||
|
let archive_path = temp_path("archive-output-targz").join("samples.tar.gz");
|
||||||
|
let extract_dir = temp_path("archive-extract-targz");
|
||||||
|
|
||||||
|
fs::create_dir_all(&nested_dir).expect("create nested source dir");
|
||||||
|
fs::write(nested_dir.join("kick.txt"), "four-on-the-floor").expect("write nested file");
|
||||||
|
|
||||||
|
let compress_result = compress(&ArchiveJobSpec {
|
||||||
|
source: source_dir.display().to_string(),
|
||||||
|
destination: archive_path.display().to_string(),
|
||||||
|
})
|
||||||
|
.expect("compress tar.gz directory");
|
||||||
|
assert_eq!(compress_result.processed_entries, 1);
|
||||||
|
assert!(archive_path.exists());
|
||||||
|
|
||||||
|
let extract_result = extract(&ArchiveJobSpec {
|
||||||
|
source: archive_path.display().to_string(),
|
||||||
|
destination: extract_dir.display().to_string(),
|
||||||
|
})
|
||||||
|
.expect("extract tar.gz archive");
|
||||||
|
assert_eq!(extract_result.processed_entries, 1);
|
||||||
|
|
||||||
|
let extracted_file = extract_dir
|
||||||
|
.join(source_dir.file_name().expect("source dir name"))
|
||||||
|
.join("drums")
|
||||||
|
.join("kicks")
|
||||||
|
.join("kick.txt");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(extracted_file).expect("read extracted tar.gz file"),
|
||||||
|
"four-on-the-floor"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&source_dir);
|
||||||
|
let _ = fs::remove_file(&archive_path);
|
||||||
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/fbrowser-audio/Cargo.toml
Normal file
12
crates/fbrowser-audio/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "fbrowser-audio"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
rodio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
symphonia.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
287
crates/fbrowser-audio/src/lib.rs
Normal file
287
crates/fbrowser-audio/src/lib.rs
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{self, BufReader, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use symphonia::core::audio::SampleBuffer;
|
||||||
|
use symphonia::core::codecs::DecoderOptions;
|
||||||
|
use symphonia::core::formats::FormatOptions;
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::default::{get_codecs, get_probe};
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
|
const PREVIEWABLE_ARCHIVE_EXTENSIONS: &[&str] = &[
|
||||||
|
"wav", "wave", "mp3", "flac", "aif", "aiff", "aifc", "ogg", "m4a", "aac",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LoopRegion {
|
||||||
|
pub start_ms: u64,
|
||||||
|
pub end_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlaybackState {
|
||||||
|
pub loaded_path: Option<String>,
|
||||||
|
pub is_playing: bool,
|
||||||
|
pub volume: f32,
|
||||||
|
pub position_ms: u64,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub loop_region: Option<LoopRegion>,
|
||||||
|
pub output_device: Option<String>,
|
||||||
|
pub media_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlaybackState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
loaded_path: None,
|
||||||
|
is_playing: false,
|
||||||
|
volume: 0.8,
|
||||||
|
position_ms: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
loop_region: None,
|
||||||
|
output_device: Some("Default".into()),
|
||||||
|
media_kind: "audio".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaybackInner {
|
||||||
|
_stream: MixerDeviceSink,
|
||||||
|
player: Player,
|
||||||
|
state: PlaybackState,
|
||||||
|
temp_preview_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AudioEngine {
|
||||||
|
inner: Arc<Mutex<PlaybackInner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEngine {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let stream = DeviceSinkBuilder::open_default_sink()?;
|
||||||
|
let player = Player::connect_new(&stream.mixer());
|
||||||
|
player.set_volume(0.8);
|
||||||
|
Ok(Self {
|
||||||
|
inner: Arc::new(Mutex::new(PlaybackInner {
|
||||||
|
_stream: stream,
|
||||||
|
player,
|
||||||
|
state: PlaybackState::default(),
|
||||||
|
temp_preview_path: None,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self, path: &str, media_kind: &str) -> Result<PlaybackState> {
|
||||||
|
let (source_path, display_path, temp_preview_path) = prepare_playback_source(path, media_kind)?;
|
||||||
|
let file = BufReader::new(File::open(&source_path)?);
|
||||||
|
let decoder = Decoder::try_from(file)?;
|
||||||
|
let duration_ms = decoder
|
||||||
|
.total_duration()
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
cleanup_temp_preview(inner.temp_preview_path.take());
|
||||||
|
inner.player.stop();
|
||||||
|
inner.player.clear();
|
||||||
|
inner.player.append(decoder);
|
||||||
|
inner.player.pause();
|
||||||
|
inner.player.set_volume(inner.state.volume);
|
||||||
|
inner.temp_preview_path = temp_preview_path;
|
||||||
|
inner.state.loaded_path = Some(display_path);
|
||||||
|
inner.state.duration_ms = duration_ms;
|
||||||
|
inner.state.position_ms = 0;
|
||||||
|
inner.state.media_kind = media_kind.to_string();
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
Ok(inner.state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
inner.player.play();
|
||||||
|
inner.state.is_playing = true;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
inner.state.position_ms = current_position_locked(&inner);
|
||||||
|
inner.player.pause();
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
inner.player.stop();
|
||||||
|
inner.player.clear();
|
||||||
|
inner.state.position_ms = 0;
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, position_ms: u64) -> Result<PlaybackState> {
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
inner
|
||||||
|
.player
|
||||||
|
.try_seek(Duration::from_millis(position_ms))
|
||||||
|
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
|
||||||
|
inner.state.position_ms = position_ms;
|
||||||
|
Ok(inner.state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume(&self, volume: f32) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
inner.player.set_volume(volume);
|
||||||
|
inner.state.volume = volume;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_loop_region(&self, region: Option<LoopRegion>) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
inner.state.loop_region = region;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("audio engine poisoned");
|
||||||
|
let position = current_position_locked(&inner);
|
||||||
|
inner.state.position_ms = position;
|
||||||
|
if let Some(region) = inner.state.loop_region.clone() {
|
||||||
|
if inner.state.is_playing && position >= region.end_ms {
|
||||||
|
let _ = inner.player.try_seek(Duration::from_millis(region.start_ms));
|
||||||
|
inner.state.position_ms = region.start_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_waveform(path: &str, bars: usize) -> Result<Vec<f32>> {
|
||||||
|
let file = File::open(Path::new(path))?;
|
||||||
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
let probe = get_probe().format(
|
||||||
|
&Default::default(),
|
||||||
|
mss,
|
||||||
|
&FormatOptions::default(),
|
||||||
|
&MetadataOptions::default(),
|
||||||
|
)?;
|
||||||
|
let mut format = probe.format;
|
||||||
|
let track = format
|
||||||
|
.default_track()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no default audio track"))?;
|
||||||
|
let mut decoder = get_codecs().make(&track.codec_params, &DecoderOptions::default())?;
|
||||||
|
let mut peaks = Vec::new();
|
||||||
|
|
||||||
|
while let Ok(packet) = format.next_packet() {
|
||||||
|
let decoded = decoder.decode(&packet)?;
|
||||||
|
let channels = decoded.spec().channels.count();
|
||||||
|
let frames = decoded.frames();
|
||||||
|
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, *decoded.spec());
|
||||||
|
samples.copy_interleaved_ref(decoded);
|
||||||
|
let chunk = samples.samples();
|
||||||
|
if chunk.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stride = channels.max(1);
|
||||||
|
for frame in 0..frames {
|
||||||
|
let idx = frame * stride;
|
||||||
|
if let Some(sample) = chunk.get(idx) {
|
||||||
|
peaks.push(sample.abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if peaks.is_empty() {
|
||||||
|
return Ok(vec![0.0; bars.max(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket_size = (peaks.len() / bars.max(1)).max(1);
|
||||||
|
let mut output = Vec::with_capacity(bars.max(1));
|
||||||
|
for chunk in peaks.chunks(bucket_size).take(bars.max(1)) {
|
||||||
|
output.push(chunk.iter().copied().fold(0.0_f32, f32::max));
|
||||||
|
}
|
||||||
|
while output.len() < bars.max(1) {
|
||||||
|
output.push(0.0);
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_playback_source(path: &str, media_kind: &str) -> Result<(PathBuf, String, Option<PathBuf>)> {
|
||||||
|
if media_kind != "archive" {
|
||||||
|
let source_path = PathBuf::from(path);
|
||||||
|
return Ok((source_path, path.to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive_path = Path::new(path);
|
||||||
|
let extension = archive_path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if extension != "zip" {
|
||||||
|
bail!("archive preview is currently supported for .zip files only");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (temp_path, entry_name) = extract_preview_from_zip(archive_path)?;
|
||||||
|
Ok((
|
||||||
|
temp_path.clone(),
|
||||||
|
format!("{} :: {}", archive_path.display(), entry_name),
|
||||||
|
Some(temp_path),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_preview_from_zip(archive_path: &Path) -> Result<(PathBuf, String)> {
|
||||||
|
let file = File::open(archive_path).with_context(|| format!("failed to open archive {}", archive_path.display()))?;
|
||||||
|
let mut archive = ZipArchive::new(file)?;
|
||||||
|
|
||||||
|
for index in 0..archive.len() {
|
||||||
|
let mut entry = archive.by_index(index)?;
|
||||||
|
if entry.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry_name = entry.name().to_string();
|
||||||
|
let extension = Path::new(&entry_name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !PREVIEWABLE_ARCHIVE_EXTENSIONS.contains(&extension.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_path = unique_preview_path(&extension);
|
||||||
|
let mut output = File::create(&temp_path)?;
|
||||||
|
io::copy(&mut entry, &mut output)?;
|
||||||
|
output.flush()?;
|
||||||
|
return Ok((temp_path, entry_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("no previewable audio files found inside {}", archive_path.display())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_preview_path(extension: &str) -> PathBuf {
|
||||||
|
let stamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("fbrowser-preview-{stamp}.{extension}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_temp_preview(path: Option<PathBuf>) {
|
||||||
|
if let Some(path) = path {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_position_locked(inner: &PlaybackInner) -> u64 {
|
||||||
|
inner.player.get_pos().as_millis() as u64
|
||||||
|
}
|
||||||
17
crates/fbrowser-core/Cargo.toml
Normal file
17
crates/fbrowser-core/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "fbrowser-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
ignore.workspace = true
|
||||||
|
lofty.workspace = true
|
||||||
|
rayon.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
walkdir.workspace = true
|
||||||
558
crates/fbrowser-core/src/db.rs
Normal file
558
crates/fbrowser-core/src/db.rs
Normal file
@ -0,0 +1,558 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::Utc;
|
||||||
|
use lofty::config::WriteOptions;
|
||||||
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
|
use lofty::probe::Probe;
|
||||||
|
use lofty::tag::{Accessor, ItemKey, ItemValue, Tag, TagItem};
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AnnotationUpdate, CollectionItemsMutation, CollectionMutation, CollectionRecord, LibraryRoot,
|
||||||
|
MediaItemDetail, MediaItemSummary, MetadataPatch, NewMediaItem, ReorderCollectionMutation,
|
||||||
|
SearchRequest, SearchResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppDatabase {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppDatabase {
|
||||||
|
pub async fn connect(database_path: &str) -> Result<Self> {
|
||||||
|
let options = SqliteConnectOptions::from_str(database_path)?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.foreign_keys(true);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(8)
|
||||||
|
.connect_with(options)
|
||||||
|
.await?;
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool(&self) -> &SqlitePool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_roots(&self) -> Result<Vec<LibraryRoot>> {
|
||||||
|
let roots = sqlx::query_as::<_, LibraryRoot>(
|
||||||
|
"SELECT id, path, enabled, platform, created_at, updated_at, item_count FROM library_roots ORDER BY path ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_root(&self, path: &str) -> Result<LibraryRoot> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO library_roots(path, enabled, platform, created_at, updated_at, item_count)
|
||||||
|
VALUES(?1, 1, ?2, ?3, ?4, 0)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET updated_at = excluded.updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(path)
|
||||||
|
.bind(std::env::consts::OS)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let root = sqlx::query_as::<_, LibraryRoot>(
|
||||||
|
"SELECT id, path, enabled, platform, created_at, updated_at, item_count FROM library_roots WHERE path = ?1",
|
||||||
|
)
|
||||||
|
.bind(path)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_root(&self, root_id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM library_roots WHERE id = ?1")
|
||||||
|
.bind(root_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_root_media(&self, root_id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM media_items WHERE root_id = ?1")
|
||||||
|
.bind(root_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE library_roots SET item_count = 0, updated_at = ?2 WHERE id = ?1")
|
||||||
|
.bind(root_id)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_media_item(&self, item: &NewMediaItem) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO media_items(
|
||||||
|
root_id, absolute_path, file_name, extension, media_kind, size_bytes, mtime_unix,
|
||||||
|
duration_ms, sample_rate, channels, bpm, musical_key, waveform_cache_key,
|
||||||
|
title, artist, album, genre, year, comment, embedded_bpm
|
||||||
|
)
|
||||||
|
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
|
||||||
|
ON CONFLICT(absolute_path) DO UPDATE SET
|
||||||
|
root_id=excluded.root_id,
|
||||||
|
file_name=excluded.file_name,
|
||||||
|
extension=excluded.extension,
|
||||||
|
media_kind=excluded.media_kind,
|
||||||
|
size_bytes=excluded.size_bytes,
|
||||||
|
mtime_unix=excluded.mtime_unix,
|
||||||
|
duration_ms=excluded.duration_ms,
|
||||||
|
sample_rate=excluded.sample_rate,
|
||||||
|
channels=excluded.channels,
|
||||||
|
bpm=excluded.bpm,
|
||||||
|
musical_key=excluded.musical_key,
|
||||||
|
waveform_cache_key=excluded.waveform_cache_key,
|
||||||
|
title=excluded.title,
|
||||||
|
artist=excluded.artist,
|
||||||
|
album=excluded.album,
|
||||||
|
genre=excluded.genre,
|
||||||
|
year=excluded.year,
|
||||||
|
comment=excluded.comment,
|
||||||
|
embedded_bpm=excluded.embedded_bpm
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(item.root_id)
|
||||||
|
.bind(&item.absolute_path)
|
||||||
|
.bind(&item.file_name)
|
||||||
|
.bind(&item.extension)
|
||||||
|
.bind(&item.media_kind)
|
||||||
|
.bind(item.size_bytes)
|
||||||
|
.bind(item.mtime_unix)
|
||||||
|
.bind(item.duration_ms)
|
||||||
|
.bind(item.sample_rate)
|
||||||
|
.bind(item.channels)
|
||||||
|
.bind(item.bpm)
|
||||||
|
.bind(&item.musical_key)
|
||||||
|
.bind(&item.waveform_cache_key)
|
||||||
|
.bind(&item.title)
|
||||||
|
.bind(&item.artist)
|
||||||
|
.bind(&item.album)
|
||||||
|
.bind(&item.genre)
|
||||||
|
.bind(&item.year)
|
||||||
|
.bind(&item.comment)
|
||||||
|
.bind(item.embedded_bpm)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn finalize_root_scan(&self, root_id: i64) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE library_roots
|
||||||
|
SET item_count = (SELECT COUNT(*) FROM media_items WHERE root_id = ?1),
|
||||||
|
updated_at = ?2
|
||||||
|
WHERE id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(root_id)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_library(&self, req: SearchRequest) -> Result<SearchResponse> {
|
||||||
|
let mut select = QueryBuilder::<Sqlite>::new(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
mi.id, mi.root_id, mi.absolute_path, mi.file_name, mi.extension, mi.media_kind,
|
||||||
|
mi.size_bytes, mi.mtime_unix, mi.duration_ms, mi.sample_rate, mi.channels,
|
||||||
|
mi.bpm, mi.musical_key, mi.title, mi.artist, mi.album, mi.genre, mi.year,
|
||||||
|
mi.comment, mi.embedded_bpm,
|
||||||
|
COALESCE(ua.favorite, 0) AS favorite,
|
||||||
|
ua.rating,
|
||||||
|
ua.note,
|
||||||
|
ua.custom_tags_json,
|
||||||
|
ua.color,
|
||||||
|
(SELECT MAX(ph.played_at) FROM play_history ph WHERE ph.media_item_id = mi.id) AS last_played_at
|
||||||
|
FROM media_items mi
|
||||||
|
LEFT JOIN user_annotations ua ON ua.media_item_id = mi.id
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
self.apply_filters(&mut select, &req);
|
||||||
|
self.apply_sort(&mut select, req.sort.as_deref());
|
||||||
|
let offset = (req.page.saturating_mul(req.page_size)) as i64;
|
||||||
|
select.push(" LIMIT ").push_bind(req.page_size as i64);
|
||||||
|
select.push(" OFFSET ").push_bind(offset);
|
||||||
|
let items = select
|
||||||
|
.build_query_as::<MediaItemSummary>()
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut count = QueryBuilder::<Sqlite>::new(
|
||||||
|
"SELECT COUNT(*) AS count FROM media_items mi LEFT JOIN user_annotations ua ON ua.media_item_id = mi.id ",
|
||||||
|
);
|
||||||
|
self.apply_filters(&mut count, &req);
|
||||||
|
let total = count
|
||||||
|
.build()
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?
|
||||||
|
.get::<i64, _>("count");
|
||||||
|
|
||||||
|
Ok(SearchResponse {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: req.page,
|
||||||
|
page_size: req.page_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_item(&self, item_id: i64) -> Result<MediaItemDetail> {
|
||||||
|
let summary = sqlx::query_as::<_, MediaItemSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
mi.id, mi.root_id, mi.absolute_path, mi.file_name, mi.extension, mi.media_kind,
|
||||||
|
mi.size_bytes, mi.mtime_unix, mi.duration_ms, mi.sample_rate, mi.channels,
|
||||||
|
mi.bpm, mi.musical_key, mi.title, mi.artist, mi.album, mi.genre, mi.year,
|
||||||
|
mi.comment, mi.embedded_bpm,
|
||||||
|
COALESCE(ua.favorite, 0) AS favorite,
|
||||||
|
ua.rating,
|
||||||
|
ua.note,
|
||||||
|
ua.custom_tags_json,
|
||||||
|
ua.color,
|
||||||
|
(SELECT MAX(ph.played_at) FROM play_history ph WHERE ph.media_item_id = mi.id) AS last_played_at
|
||||||
|
FROM media_items mi
|
||||||
|
LEFT JOIN user_annotations ua ON ua.media_item_id = mi.id
|
||||||
|
WHERE mi.id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let custom_tags = summary
|
||||||
|
.custom_tags_json
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(MediaItemDetail { summary, custom_tags })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_annotations(&self, update: AnnotationUpdate) -> Result<MediaItemDetail> {
|
||||||
|
let existing = sqlx::query(
|
||||||
|
"SELECT favorite, rating, note, custom_tags_json, color FROM user_annotations WHERE media_item_id = ?1",
|
||||||
|
)
|
||||||
|
.bind(update.item_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let favorite = update.favorite.unwrap_or_else(|| {
|
||||||
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|row| row.try_get::<i64, _>("favorite").ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
!= 0
|
||||||
|
});
|
||||||
|
let rating = update
|
||||||
|
.rating
|
||||||
|
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<i64>, _>("rating").ok().flatten()));
|
||||||
|
let note = update
|
||||||
|
.note
|
||||||
|
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<String>, _>("note").ok().flatten()));
|
||||||
|
let custom_tags_json = update
|
||||||
|
.custom_tags
|
||||||
|
.map(|tags| serde_json::to_string(&tags))
|
||||||
|
.transpose()?
|
||||||
|
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<String>, _>("custom_tags_json").ok().flatten()));
|
||||||
|
let color = update
|
||||||
|
.color
|
||||||
|
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<String>, _>("color").ok().flatten()));
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO user_annotations(media_item_id, favorite, rating, note, custom_tags_json, color, updated_at)
|
||||||
|
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(media_item_id) DO UPDATE SET
|
||||||
|
favorite=excluded.favorite,
|
||||||
|
rating=excluded.rating,
|
||||||
|
note=excluded.note,
|
||||||
|
custom_tags_json=excluded.custom_tags_json,
|
||||||
|
color=excluded.color,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(update.item_id)
|
||||||
|
.bind(if favorite { 1 } else { 0 })
|
||||||
|
.bind(rating)
|
||||||
|
.bind(note)
|
||||||
|
.bind(custom_tags_json)
|
||||||
|
.bind(color)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
self.get_item(update.item_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_play_history(&self, item_id: i64, source_context: &str) -> Result<()> {
|
||||||
|
sqlx::query("INSERT INTO play_history(media_item_id, played_at, source_context) VALUES(?1, ?2, ?3)")
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.bind(source_context)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_item_id_by_path(&self, path: &str) -> Result<Option<i64>> {
|
||||||
|
let row = sqlx::query("SELECT id FROM media_items WHERE absolute_path = ?1")
|
||||||
|
.bind(path)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| row.get::<i64, _>("id")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_path_for_item(&self, item_id: i64) -> Result<String> {
|
||||||
|
let row = sqlx::query("SELECT absolute_path FROM media_items WHERE id = ?1")
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get::<String, _>("absolute_path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_metadata(&self, item_id: i64, patch: MetadataPatch) -> Result<MediaItemDetail> {
|
||||||
|
let path = self.get_path_for_item(item_id).await?;
|
||||||
|
let mut tagged = Probe::open(&path)
|
||||||
|
.with_context(|| format!("failed to open media file for metadata write: {path}"))?
|
||||||
|
.read()
|
||||||
|
.with_context(|| format!("failed to read media file for metadata write: {path}"))?;
|
||||||
|
|
||||||
|
let primary = tagged.primary_tag_type();
|
||||||
|
if tagged.primary_tag_mut().is_none() {
|
||||||
|
tagged.insert_tag(Tag::new(primary));
|
||||||
|
}
|
||||||
|
let tag = if let Some(tag) = tagged.primary_tag_mut() {
|
||||||
|
tag
|
||||||
|
} else {
|
||||||
|
tagged
|
||||||
|
.first_tag_mut()
|
||||||
|
.context("no writable metadata tag available")?
|
||||||
|
};
|
||||||
|
if let Some(value) = &patch.title {
|
||||||
|
tag.set_title(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = &patch.artist {
|
||||||
|
tag.set_artist(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = &patch.album {
|
||||||
|
tag.set_album(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = &patch.genre {
|
||||||
|
tag.set_genre(value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = &patch.year {
|
||||||
|
tag.insert(TagItem::new(ItemKey::RecordingDate, ItemValue::Text(value.clone())));
|
||||||
|
}
|
||||||
|
if let Some(value) = &patch.comment {
|
||||||
|
tag.insert(TagItem::new(ItemKey::Comment, ItemValue::Text(value.clone())));
|
||||||
|
}
|
||||||
|
tagged.save_to_path(&path, WriteOptions::default())?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE media_items
|
||||||
|
SET title = COALESCE(?2, title),
|
||||||
|
artist = COALESCE(?3, artist),
|
||||||
|
album = COALESCE(?4, album),
|
||||||
|
genre = COALESCE(?5, genre),
|
||||||
|
year = COALESCE(?6, year),
|
||||||
|
comment = COALESCE(?7, comment)
|
||||||
|
WHERE id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(patch.title)
|
||||||
|
.bind(patch.artist)
|
||||||
|
.bind(patch.album)
|
||||||
|
.bind(patch.genre)
|
||||||
|
.bind(patch.year)
|
||||||
|
.bind(patch.comment)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
self.get_item(item_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_collections(&self) -> Result<Vec<CollectionRecord>> {
|
||||||
|
let collections = sqlx::query_as::<_, CollectionRecord>(
|
||||||
|
"SELECT id, name, kind, rules_json, created_at FROM collections ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(collections)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_collection(&self, payload: CollectionMutation) -> Result<CollectionRecord> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let result =
|
||||||
|
sqlx::query("INSERT INTO collections(name, kind, rules_json, created_at) VALUES(?1, ?2, ?3, ?4)")
|
||||||
|
.bind(payload.name)
|
||||||
|
.bind(payload.kind)
|
||||||
|
.bind(payload.rules_json)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let id = result.last_insert_rowid();
|
||||||
|
let collection = sqlx::query_as::<_, CollectionRecord>(
|
||||||
|
"SELECT id, name, kind, rules_json, created_at FROM collections WHERE id = ?1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_collection(&self, id: i64, payload: CollectionMutation) -> Result<CollectionRecord> {
|
||||||
|
sqlx::query("UPDATE collections SET name = ?2, kind = ?3, rules_json = ?4 WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(payload.name)
|
||||||
|
.bind(payload.kind)
|
||||||
|
.bind(payload.rules_json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let collection = sqlx::query_as::<_, CollectionRecord>(
|
||||||
|
"SELECT id, name, kind, rules_json, created_at FROM collections WHERE id = ?1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_collection(&self, id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM collections WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_items_to_collection(&self, payload: CollectionItemsMutation) -> Result<()> {
|
||||||
|
for (position, item_id) in payload.item_ids.into_iter().enumerate() {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO collection_items(collection_id, media_item_id, position)
|
||||||
|
VALUES(?1, ?2, ?3)
|
||||||
|
ON CONFLICT(collection_id, media_item_id) DO UPDATE SET position = excluded.position
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(payload.collection_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(position as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_items_from_collection(&self, payload: CollectionItemsMutation) -> Result<()> {
|
||||||
|
for item_id in payload.item_ids {
|
||||||
|
sqlx::query("DELETE FROM collection_items WHERE collection_id = ?1 AND media_item_id = ?2")
|
||||||
|
.bind(payload.collection_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reorder_collection(&self, payload: ReorderCollectionMutation) -> Result<()> {
|
||||||
|
for (position, item_id) in payload.item_ids.into_iter().enumerate() {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE collection_items SET position = ?3 WHERE collection_id = ?1 AND media_item_id = ?2",
|
||||||
|
)
|
||||||
|
.bind(payload.collection_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(position as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_setting_json<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
|
||||||
|
let row = sqlx::query("SELECT value FROM settings WHERE key = ?1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if let Some(row) = row {
|
||||||
|
let value = row.get::<String, _>("value");
|
||||||
|
Ok(Some(serde_json::from_str(&value)?))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_setting_json<T: serde::Serialize>(&self, key: &str, value: &T) -> Result<()> {
|
||||||
|
let raw = serde_json::to_string(value)?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO settings(key, value)
|
||||||
|
VALUES(?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(raw)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_filters<'a>(&self, builder: &mut QueryBuilder<'a, Sqlite>, req: &SearchRequest) {
|
||||||
|
builder.push(" WHERE 1=1 ");
|
||||||
|
if let Some(root_id) = req.root_id {
|
||||||
|
builder.push(" AND mi.root_id = ").push_bind(root_id);
|
||||||
|
}
|
||||||
|
if let Some(media_kind) = req.media_kind.as_deref() {
|
||||||
|
builder.push(" AND mi.media_kind = ").push_bind(media_kind.to_string());
|
||||||
|
}
|
||||||
|
if let Some(query) = req.query.as_deref() {
|
||||||
|
let like = format!("%{}%", query.trim());
|
||||||
|
builder.push(" AND (mi.file_name LIKE ");
|
||||||
|
builder.push_bind(like.clone());
|
||||||
|
builder.push(" OR COALESCE(mi.title, '') LIKE ");
|
||||||
|
builder.push_bind(like.clone());
|
||||||
|
builder.push(" OR COALESCE(mi.artist, '') LIKE ");
|
||||||
|
builder.push_bind(like.clone());
|
||||||
|
builder.push(" OR COALESCE(mi.album, '') LIKE ");
|
||||||
|
builder.push_bind(like);
|
||||||
|
builder.push(")");
|
||||||
|
}
|
||||||
|
if let Some(section) = req.section.as_deref() {
|
||||||
|
match section {
|
||||||
|
"favorites" => {
|
||||||
|
builder.push(" AND COALESCE(ua.favorite, 0) = 1");
|
||||||
|
}
|
||||||
|
"recent" => {
|
||||||
|
builder.push(" AND EXISTS (SELECT 1 FROM play_history ph WHERE ph.media_item_id = mi.id)");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(collection_id) = req.collection_id {
|
||||||
|
builder.push(" AND EXISTS (SELECT 1 FROM collection_items ci WHERE ci.collection_id = ");
|
||||||
|
builder.push_bind(collection_id);
|
||||||
|
builder.push(" AND ci.media_item_id = mi.id)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_sort<'a>(&self, builder: &mut QueryBuilder<'a, Sqlite>, sort: Option<&str>) {
|
||||||
|
match sort.unwrap_or("name") {
|
||||||
|
"recent" => builder.push(" ORDER BY last_played_at DESC NULLS LAST, mi.file_name ASC "),
|
||||||
|
"rating" => builder.push(" ORDER BY ua.rating DESC NULLS LAST, mi.file_name ASC "),
|
||||||
|
"duration" => builder.push(" ORDER BY mi.duration_ms DESC NULLS LAST, mi.file_name ASC "),
|
||||||
|
_ => builder.push(" ORDER BY mi.file_name COLLATE NOCASE ASC "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/fbrowser-core/src/lib.rs
Normal file
5
crates/fbrowser-core/src/lib.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
pub mod db;
|
||||||
|
pub mod models;
|
||||||
|
pub mod scanner;
|
||||||
|
|
||||||
|
pub use db::AppDatabase;
|
||||||
151
crates/fbrowser-core/src/models.rs
Normal file
151
crates/fbrowser-core/src/models.rs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct LibraryRoot {
|
||||||
|
pub id: i64,
|
||||||
|
pub path: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub platform: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub item_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct MediaItemSummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub root_id: i64,
|
||||||
|
pub absolute_path: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub extension: String,
|
||||||
|
pub media_kind: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub mtime_unix: i64,
|
||||||
|
pub duration_ms: Option<i64>,
|
||||||
|
pub sample_rate: Option<i64>,
|
||||||
|
pub channels: Option<i64>,
|
||||||
|
pub bpm: Option<f64>,
|
||||||
|
pub musical_key: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<String>,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub embedded_bpm: Option<f64>,
|
||||||
|
pub favorite: bool,
|
||||||
|
pub rating: Option<i64>,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub custom_tags_json: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub last_played_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MediaItemDetail {
|
||||||
|
pub summary: MediaItemSummary,
|
||||||
|
pub custom_tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SearchRequest {
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub section: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub page: u32,
|
||||||
|
pub page_size: u32,
|
||||||
|
pub root_id: Option<i64>,
|
||||||
|
pub collection_id: Option<i64>,
|
||||||
|
pub media_kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SearchResponse {
|
||||||
|
pub items: Vec<MediaItemSummary>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u32,
|
||||||
|
pub page_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnnotationUpdate {
|
||||||
|
pub item_id: i64,
|
||||||
|
pub favorite: Option<bool>,
|
||||||
|
pub rating: Option<i64>,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub custom_tags: Option<Vec<String>>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MetadataPatch {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<String>,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct CollectionRecord {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub rules_json: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CollectionMutation {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub rules_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CollectionItemsMutation {
|
||||||
|
pub collection_id: i64,
|
||||||
|
pub item_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReorderCollectionMutation {
|
||||||
|
pub collection_id: i64,
|
||||||
|
pub item_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ScanStatus {
|
||||||
|
pub active: bool,
|
||||||
|
pub current_root: Option<String>,
|
||||||
|
pub indexed: u64,
|
||||||
|
pub discovered: u64,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub roots: Vec<LibraryRoot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NewMediaItem {
|
||||||
|
pub root_id: i64,
|
||||||
|
pub absolute_path: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub extension: String,
|
||||||
|
pub media_kind: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub mtime_unix: i64,
|
||||||
|
pub duration_ms: Option<i64>,
|
||||||
|
pub sample_rate: Option<i64>,
|
||||||
|
pub channels: Option<i64>,
|
||||||
|
pub bpm: Option<f64>,
|
||||||
|
pub musical_key: Option<String>,
|
||||||
|
pub waveform_cache_key: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub year: Option<String>,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub embedded_bpm: Option<f64>,
|
||||||
|
}
|
||||||
173
crates/fbrowser-core/src/scanner.rs
Normal file
173
crates/fbrowser-core/src/scanner.rs
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use ignore::WalkBuilder;
|
||||||
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
|
use lofty::probe::Probe;
|
||||||
|
use lofty::tag::Accessor;
|
||||||
|
use rayon::prelude::*;
|
||||||
|
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::models::{LibraryRoot, NewMediaItem};
|
||||||
|
|
||||||
|
const AUDIO_EXTENSIONS: &[&str] = &[
|
||||||
|
"wav", "wave", "mp3", "flac", "aif", "aiff", "aifc", "ogg", "m4a", "aac", "wma",
|
||||||
|
];
|
||||||
|
const MIDI_EXTENSIONS: &[&str] = &["mid", "midi"];
|
||||||
|
const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "gz", "tgz", "7z"];
|
||||||
|
|
||||||
|
const DISCOVERY_PROGRESS_INTERVAL: usize = 250;
|
||||||
|
const INDEX_PROGRESS_INTERVAL: usize = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScanProgress {
|
||||||
|
pub discovered: u64,
|
||||||
|
pub indexed: u64,
|
||||||
|
pub current_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_root<F>(db: &AppDatabase, root: &LibraryRoot, mut on_progress: F) -> Result<u64>
|
||||||
|
where
|
||||||
|
F: FnMut(ScanProgress) + Send,
|
||||||
|
{
|
||||||
|
db.clear_root_media(root.id).await?;
|
||||||
|
|
||||||
|
let mut candidate_paths = Vec::<(PathBuf, String)>::new();
|
||||||
|
let walker = WalkBuilder::new(&root.path)
|
||||||
|
.hidden(false)
|
||||||
|
.git_ignore(true)
|
||||||
|
.git_exclude(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for entry in walker {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(entry) => entry,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !entry.file_type().map(|file_type| file_type.is_file()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = entry.into_path();
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !AUDIO_EXTENSIONS.contains(&extension.as_str())
|
||||||
|
&& !MIDI_EXTENSIONS.contains(&extension.as_str())
|
||||||
|
&& !ARCHIVE_EXTENSIONS.contains(&extension.as_str())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate_paths.push((path.clone(), extension));
|
||||||
|
if candidate_paths.len() == 1 || candidate_paths.len() % DISCOVERY_PROGRESS_INTERVAL == 0 {
|
||||||
|
on_progress(ScanProgress {
|
||||||
|
discovered: candidate_paths.len() as u64,
|
||||||
|
indexed: 0,
|
||||||
|
current_path: Some(path.display().to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let discovered = candidate_paths.len() as u64;
|
||||||
|
let items = candidate_paths
|
||||||
|
.par_iter()
|
||||||
|
.filter_map(|(path, extension)| match build_item(root.id, path, extension) {
|
||||||
|
Ok(item) => item,
|
||||||
|
Err(_) => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut indexed = 0_u64;
|
||||||
|
for item in items {
|
||||||
|
db.insert_media_item(&item).await?;
|
||||||
|
indexed += 1;
|
||||||
|
if indexed == 1 || indexed % INDEX_PROGRESS_INTERVAL as u64 == 0 {
|
||||||
|
on_progress(ScanProgress {
|
||||||
|
discovered,
|
||||||
|
indexed,
|
||||||
|
current_path: Some(item.absolute_path.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.finalize_root_scan(root.id).await?;
|
||||||
|
on_progress(ScanProgress {
|
||||||
|
discovered,
|
||||||
|
indexed,
|
||||||
|
current_path: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(indexed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_item(root_id: i64, path: &Path, extension: &str) -> Result<Option<NewMediaItem>> {
|
||||||
|
let metadata = fs::metadata(path)?;
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| path.display().to_string());
|
||||||
|
let absolute_path = path.canonicalize()?.display().to_string();
|
||||||
|
let mtime_unix = metadata
|
||||||
|
.modified()?
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let media_kind = if AUDIO_EXTENSIONS.contains(&extension) {
|
||||||
|
"audio"
|
||||||
|
} else if MIDI_EXTENSIONS.contains(&extension) {
|
||||||
|
"midi"
|
||||||
|
} else {
|
||||||
|
"archive"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut item = NewMediaItem {
|
||||||
|
root_id,
|
||||||
|
absolute_path,
|
||||||
|
file_name,
|
||||||
|
extension: extension.to_string(),
|
||||||
|
media_kind: media_kind.to_string(),
|
||||||
|
size_bytes: metadata.len() as i64,
|
||||||
|
mtime_unix,
|
||||||
|
duration_ms: None,
|
||||||
|
sample_rate: None,
|
||||||
|
channels: None,
|
||||||
|
bpm: None,
|
||||||
|
musical_key: None,
|
||||||
|
waveform_cache_key: None,
|
||||||
|
title: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
year: None,
|
||||||
|
comment: None,
|
||||||
|
embedded_bpm: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if media_kind == "audio" {
|
||||||
|
if let Ok(tagged) = Probe::open(path)?.read() {
|
||||||
|
item.duration_ms = tagged
|
||||||
|
.properties()
|
||||||
|
.duration()
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.ok();
|
||||||
|
item.sample_rate = tagged.properties().sample_rate().map(i64::from);
|
||||||
|
item.channels = tagged.properties().channels().map(i64::from);
|
||||||
|
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
|
||||||
|
item.title = tag.title().map(|value| value.into_owned());
|
||||||
|
item.artist = tag.artist().map(|value| value.into_owned());
|
||||||
|
item.album = tag.album().map(|value| value.into_owned());
|
||||||
|
item.genre = tag.genre().map(|value| value.into_owned());
|
||||||
|
item.comment = tag.comment().map(|value| value.into_owned());
|
||||||
|
item.year = tag.year().map(|value: u32| value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(item))
|
||||||
|
}
|
||||||
12
crates/fbrowser-midi/Cargo.toml
Normal file
12
crates/fbrowser-midi/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "fbrowser-midi"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
fbrowser-audio = { path = "../fbrowser-audio" }
|
||||||
|
midir.workspace = true
|
||||||
|
midly.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
366
crates/fbrowser-midi/src/lib.rs
Normal file
366
crates/fbrowser-midi/src/lib.rs
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use fbrowser_audio::{LoopRegion, PlaybackState};
|
||||||
|
use midir::MidiOutput;
|
||||||
|
use midly::{MetaMessage, MidiMessage, Smf, Timing, TrackEventKind};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MidiBackendInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub supports_soundfont: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MidiBackendConfig {
|
||||||
|
pub backend_id: String,
|
||||||
|
pub soundfont_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MidiBackendConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend_id: "system".into(),
|
||||||
|
soundfont_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MidiEngine {
|
||||||
|
inner: Arc<Mutex<MidiPlaybackInner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MidiPlaybackInner {
|
||||||
|
state: PlaybackState,
|
||||||
|
events: Vec<ScheduledMidiEvent>,
|
||||||
|
task: Option<MidiPlaybackTask>,
|
||||||
|
play_started_at: Option<Instant>,
|
||||||
|
paused_position_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MidiPlaybackTask {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ScheduledMidiEvent {
|
||||||
|
timestamp_ms: u64,
|
||||||
|
data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MidiEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(MidiPlaybackInner {
|
||||||
|
state: PlaybackState {
|
||||||
|
media_kind: "midi".into(),
|
||||||
|
..PlaybackState::default()
|
||||||
|
},
|
||||||
|
events: Vec::new(),
|
||||||
|
task: None,
|
||||||
|
play_started_at: None,
|
||||||
|
paused_position_ms: 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self, path: &str, config: &MidiBackendConfig) -> Result<PlaybackState> {
|
||||||
|
ensure_supported_backend(config)?;
|
||||||
|
let bytes = fs::read(path).with_context(|| format!("failed to read MIDI file {path}"))?;
|
||||||
|
let (events, duration_ms) = parse_midi_events(&bytes)?;
|
||||||
|
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
stop_task_locked(&mut inner);
|
||||||
|
inner.events = events;
|
||||||
|
inner.paused_position_ms = 0;
|
||||||
|
inner.play_started_at = None;
|
||||||
|
inner.state.loaded_path = Some(path.to_string());
|
||||||
|
inner.state.duration_ms = duration_ms;
|
||||||
|
inner.state.position_ms = 0;
|
||||||
|
inner.state.volume = 0.8;
|
||||||
|
inner.state.loop_region = None;
|
||||||
|
inner.state.output_device = Some("System MIDI".into());
|
||||||
|
inner.state.media_kind = "midi".into();
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
Ok(inner.state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self, config: &MidiBackendConfig) -> Result<PlaybackState> {
|
||||||
|
ensure_supported_backend(config)?;
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
if inner.state.loaded_path.is_none() {
|
||||||
|
bail!("no MIDI file loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_task_locked(&mut inner);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_for_thread = stop.clone();
|
||||||
|
let events = inner.events.clone();
|
||||||
|
let start_offset_ms = inner.paused_position_ms;
|
||||||
|
let state = self.inner.clone();
|
||||||
|
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let midi_out = match MidiOutput::new("Fbrowser MIDI") {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let ports = midi_out.ports();
|
||||||
|
let Some(port) = ports.first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut connection = match midi_out.connect(port, "fbrowser-system-midi") {
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
for event in events.into_iter().filter(|event| event.timestamp_ms >= start_offset_ms) {
|
||||||
|
let target_offset = event.timestamp_ms.saturating_sub(start_offset_ms);
|
||||||
|
while !stop_for_thread.load(Ordering::Relaxed) {
|
||||||
|
let elapsed = start.elapsed().as_millis() as u64;
|
||||||
|
if elapsed >= target_offset {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if stop_for_thread.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = connection.send(&event.data);
|
||||||
|
if let Ok(mut inner) = state.lock() {
|
||||||
|
inner.state.position_ms = event.timestamp_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut inner) = state.lock() {
|
||||||
|
if !stop_for_thread.load(Ordering::Relaxed) {
|
||||||
|
inner.state.position_ms = inner.state.duration_ms;
|
||||||
|
inner.paused_position_ms = inner.state.duration_ms;
|
||||||
|
}
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
inner.play_started_at = None;
|
||||||
|
inner.task = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inner.task = Some(MidiPlaybackTask { stop, handle });
|
||||||
|
inner.play_started_at = Some(Instant::now());
|
||||||
|
inner.state.is_playing = true;
|
||||||
|
Ok(inner.state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
inner.paused_position_ms = current_position_locked(&inner);
|
||||||
|
stop_task_locked(&mut inner);
|
||||||
|
inner.state.position_ms = inner.paused_position_ms;
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
stop_task_locked(&mut inner);
|
||||||
|
inner.paused_position_ms = 0;
|
||||||
|
inner.state.position_ms = 0;
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, position_ms: u64, config: &MidiBackendConfig) -> Result<PlaybackState> {
|
||||||
|
ensure_supported_backend(config)?;
|
||||||
|
let was_playing = {
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
let was_playing = inner.state.is_playing;
|
||||||
|
stop_task_locked(&mut inner);
|
||||||
|
let clamped = position_ms.min(inner.state.duration_ms);
|
||||||
|
inner.paused_position_ms = clamped;
|
||||||
|
inner.state.position_ms = clamped;
|
||||||
|
inner.state.is_playing = false;
|
||||||
|
was_playing
|
||||||
|
};
|
||||||
|
|
||||||
|
if was_playing {
|
||||||
|
return self.play(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self.state())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume(&self, volume: f32) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
inner.state.volume = volume;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_loop_region(&self, region: Option<LoopRegion>) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
inner.state.loop_region = region;
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> PlaybackState {
|
||||||
|
let mut inner = self.inner.lock().expect("midi engine poisoned");
|
||||||
|
inner.state.position_ms = current_position_locked(&inner);
|
||||||
|
inner.state.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available_backends() -> Vec<MidiBackendInfo> {
|
||||||
|
vec![
|
||||||
|
MidiBackendInfo {
|
||||||
|
id: "system".into(),
|
||||||
|
label: "System MIDI Output".into(),
|
||||||
|
supports_soundfont: false,
|
||||||
|
},
|
||||||
|
MidiBackendInfo {
|
||||||
|
id: "soundfont".into(),
|
||||||
|
label: "User SoundFont".into(),
|
||||||
|
supports_soundfont: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_supported_backend(config: &MidiBackendConfig) -> Result<()> {
|
||||||
|
match config.backend_id.as_str() {
|
||||||
|
"system" => Ok(()),
|
||||||
|
"soundfont" => bail!("SoundFont MIDI playback backend is not implemented yet"),
|
||||||
|
other => bail!("unsupported MIDI backend: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_midi_events(bytes: &[u8]) -> Result<(Vec<ScheduledMidiEvent>, u64)> {
|
||||||
|
let smf = Smf::parse(bytes)?;
|
||||||
|
let ticks_per_beat = match smf.header.timing {
|
||||||
|
Timing::Metrical(ticks) => u64::from(ticks.as_int()),
|
||||||
|
Timing::Timecode(_, _) => bail!("timecode-based MIDI timing is not supported yet"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut raw_events = Vec::<(u64, RawMidiEvent)>::new();
|
||||||
|
for track in &smf.tracks {
|
||||||
|
let mut tick_position = 0_u64;
|
||||||
|
for event in track {
|
||||||
|
tick_position += u64::from(event.delta.as_int());
|
||||||
|
match event.kind {
|
||||||
|
TrackEventKind::Midi { channel, message } => {
|
||||||
|
if let Some(data) = midi_message_to_bytes(channel.as_int(), message) {
|
||||||
|
raw_events.push((tick_position, RawMidiEvent::Message(data)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrackEventKind::Meta(MetaMessage::Tempo(tempo)) => {
|
||||||
|
raw_events.push((tick_position, RawMidiEvent::Tempo(tempo.as_int())));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_events.sort_by_key(|(tick, _)| *tick);
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut current_tempo_us_per_beat = 500_000_u64;
|
||||||
|
let mut previous_tick = 0_u64;
|
||||||
|
let mut elapsed_us = 0_u64;
|
||||||
|
|
||||||
|
for (tick, raw_event) in raw_events {
|
||||||
|
let delta_ticks = tick.saturating_sub(previous_tick);
|
||||||
|
elapsed_us = elapsed_us.saturating_add(
|
||||||
|
delta_ticks
|
||||||
|
.saturating_mul(current_tempo_us_per_beat)
|
||||||
|
.checked_div(ticks_per_beat)
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
previous_tick = tick;
|
||||||
|
|
||||||
|
match raw_event {
|
||||||
|
RawMidiEvent::Message(data) => events.push(ScheduledMidiEvent {
|
||||||
|
timestamp_ms: elapsed_us / 1000,
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
RawMidiEvent::Tempo(next_tempo) => {
|
||||||
|
current_tempo_us_per_beat = u64::from(next_tempo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration_ms = events.last().map(|event| event.timestamp_ms).unwrap_or(0);
|
||||||
|
Ok((events, duration_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn midi_message_to_bytes(channel: u8, message: MidiMessage) -> Option<Vec<u8>> {
|
||||||
|
let status_base = match message {
|
||||||
|
MidiMessage::NoteOff { .. } => 0x80,
|
||||||
|
MidiMessage::NoteOn { .. } => 0x90,
|
||||||
|
MidiMessage::Aftertouch { .. } => 0xA0,
|
||||||
|
MidiMessage::Controller { .. } => 0xB0,
|
||||||
|
MidiMessage::ProgramChange { .. } => 0xC0,
|
||||||
|
MidiMessage::ChannelAftertouch { .. } => 0xD0,
|
||||||
|
MidiMessage::PitchBend { .. } => 0xE0,
|
||||||
|
};
|
||||||
|
let status = status_base | (channel & 0x0F);
|
||||||
|
|
||||||
|
Some(match message {
|
||||||
|
MidiMessage::NoteOff { key, vel } => vec![status, key.as_int(), vel.as_int()],
|
||||||
|
MidiMessage::NoteOn { key, vel } => vec![status, key.as_int(), vel.as_int()],
|
||||||
|
MidiMessage::Aftertouch { key, vel } => vec![status, key.as_int(), vel.as_int()],
|
||||||
|
MidiMessage::Controller { controller, value } => vec![status, controller.as_int(), value.as_int()],
|
||||||
|
MidiMessage::ProgramChange { program } => vec![status, program.as_int()],
|
||||||
|
MidiMessage::ChannelAftertouch { vel } => vec![status, vel.as_int()],
|
||||||
|
MidiMessage::PitchBend { bend } => {
|
||||||
|
let value = bend.as_int();
|
||||||
|
vec![status, (value & 0x7F) as u8, ((value >> 7) & 0x7F) as u8]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_task_locked(inner: &mut MidiPlaybackInner) {
|
||||||
|
if let Some(task) = inner.task.take() {
|
||||||
|
task.stop.store(true, Ordering::Relaxed);
|
||||||
|
let _ = task.handle.join();
|
||||||
|
}
|
||||||
|
inner.play_started_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_position_locked(inner: &MidiPlaybackInner) -> u64 {
|
||||||
|
if inner.state.is_playing {
|
||||||
|
if let Some(started_at) = inner.play_started_at {
|
||||||
|
return (inner.paused_position_ms + started_at.elapsed().as_millis() as u64)
|
||||||
|
.min(inner.state.duration_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inner.paused_position_ms.min(inner.state.duration_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RawMidiEvent {
|
||||||
|
Message(Vec<u8>),
|
||||||
|
Tempo(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{available_backends, MidiBackendConfig};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_uses_system_backend_without_soundfont() {
|
||||||
|
let config = MidiBackendConfig::default();
|
||||||
|
assert_eq!(config.backend_id, "system");
|
||||||
|
assert_eq!(config.soundfont_path, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn available_backends_include_system_and_soundfont_modes() {
|
||||||
|
let backends = available_backends();
|
||||||
|
assert!(backends.iter().any(|backend| backend.id == "system" && !backend.supports_soundfont));
|
||||||
|
assert!(backends.iter().any(|backend| backend.id == "soundfont" && backend.supports_soundfont));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
crates/fbrowser-plugin-core/Cargo.toml
Normal file
8
crates/fbrowser-plugin-core/Cargo.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "fbrowser-plugin-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
12
crates/fbrowser-plugin-core/src/lib.rs
Normal file
12
crates/fbrowser-plugin-core/src/lib.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginPreviewRequest {
|
||||||
|
pub path: String,
|
||||||
|
pub start_ms: u64,
|
||||||
|
pub end_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait PreviewHost {
|
||||||
|
fn preview(&self, request: PluginPreviewRequest);
|
||||||
|
}
|
||||||
@ -1,11 +0,0 @@
|
|||||||
version: '3.4'
|
|
||||||
|
|
||||||
services:
|
|
||||||
fbrowser:
|
|
||||||
image: fbrowser
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
command: ["sh", "-c", "pip install debugpy -t /tmp && python /tmp/debugpy --wait-for-client --listen 0.0.0.0:5678 Fbrowser.py "]
|
|
||||||
ports:
|
|
||||||
- 5678:5678
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
version: '3.4'
|
|
||||||
|
|
||||||
services:
|
|
||||||
fbrowser:
|
|
||||||
image: fbrowser
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
18
index.html
Normal file
18
index.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Fbrowser</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-canvas text-text">
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3596
package-lock.json
generated
Normal file
3596
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "fbrowser-desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 1420",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.76.1",
|
||||||
|
"@tanstack/react-virtual": "^3.13.5",
|
||||||
|
"@tauri-apps/api": "^2.5.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.3.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.9.2",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.2.5",
|
||||||
|
"vitest": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
paq-8l_intel.exe
BIN
paq-8l_intel.exe
Binary file not shown.
@ -1,102 +0,0 @@
|
|||||||
; YASM x86-64 assembly language code for PAQ7/8 ver. 2, Jan 18, 2007
|
|
||||||
;
|
|
||||||
; (C) 2005-2007, Matt Mahoney, Matthew Fite.
|
|
||||||
; This is free software under GPL, http://www.gnu.org/licenses/gpl.txt
|
|
||||||
;
|
|
||||||
; This code was tested on an Athlon-64 under Ubuntu Linux 2.6.15.27.amd64-generic
|
|
||||||
; with paq8f and paq8jd. It should work with any PAQ version since paq7,
|
|
||||||
; because all versions use the same paq7asm.asm code for 32 bit Windows/Linux
|
|
||||||
; versions. To compile e.g. paq8jd in Linux:
|
|
||||||
;
|
|
||||||
; yasm paq7asm-x86_64.asm -f elf -m amd64
|
|
||||||
; g++ -O3 -s -fomit-frame-pointer -DUNIX paq8jd.cpp paq7asm-x86_64.o -o paq8jd
|
|
||||||
;
|
|
||||||
; This code has not been tested in Windows. (You would need XP Professional
|
|
||||||
; 64 bit edition and a 64 bit compiler).
|
|
||||||
|
|
||||||
section .text
|
|
||||||
|
|
||||||
BITS 64
|
|
||||||
|
|
||||||
; Vector product a*b of n signed words, returning signed dword scaled
|
|
||||||
; down by 8 bits. n is rounded up to a multiple of 8.
|
|
||||||
|
|
||||||
global dot_product ; (short* a, short* b, int n)
|
|
||||||
align 16
|
|
||||||
dot_product:
|
|
||||||
mov rcx, rdx ; n
|
|
||||||
mov rax, rdi ; a
|
|
||||||
mov rdx, rsi ; b
|
|
||||||
add rcx, 7 ; n rounding up
|
|
||||||
and rcx, -8
|
|
||||||
jz .done
|
|
||||||
sub rax, 16
|
|
||||||
sub rdx, 16
|
|
||||||
pxor xmm0, xmm0 ; sum = 0
|
|
||||||
.loop: ; each loop sums 4 products
|
|
||||||
movdqa xmm1, [rax+rcx*2] ; put parital sums of vector product in xmm1
|
|
||||||
pmaddwd xmm1, [rdx+rcx*2]
|
|
||||||
psrad xmm1, 8
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
sub rcx, 8
|
|
||||||
ja .loop
|
|
||||||
movdqa xmm1, xmm0 ; add 4 parts of xmm0 and return in eax
|
|
||||||
psrldq xmm1, 8
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
movdqa xmm1, xmm0
|
|
||||||
psrldq xmm1, 4
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
movd rax, xmm0
|
|
||||||
.done
|
|
||||||
ret
|
|
||||||
|
|
||||||
; Train n neural network weights w[n] on inputs t[n] and err.
|
|
||||||
; w[i] += (t[i]*err*2 >> 16)+1 >> 1 bounded to +- 32K.
|
|
||||||
; n is rounded up to a multiple of 8.
|
|
||||||
|
|
||||||
;1st arg rdi -> *t
|
|
||||||
;2nd arg rsi -> *w
|
|
||||||
;3rd arg rdx -> n
|
|
||||||
;4th arg rcx -> err (signed 16 bits)
|
|
||||||
|
|
||||||
global train ; (short* t, short* w, int n, int err)
|
|
||||||
BITS 64
|
|
||||||
align 16
|
|
||||||
train:
|
|
||||||
mov rax, rcx ; err
|
|
||||||
and rax, 0xffff ; put 8 copies of err in xmm0
|
|
||||||
movd xmm0, rax
|
|
||||||
movd xmm1, rax
|
|
||||||
pslldq xmm1, 2
|
|
||||||
por xmm0, xmm1
|
|
||||||
movdqa xmm1, xmm0
|
|
||||||
pslldq xmm1, 4
|
|
||||||
por xmm0, xmm1
|
|
||||||
movdqa xmm1, xmm0
|
|
||||||
pslldq xmm1, 8
|
|
||||||
por xmm0, xmm1;
|
|
||||||
pcmpeqb xmm1, xmm1 ; 8 copies of 1 in xmm1
|
|
||||||
psrlw xmm1, 15
|
|
||||||
mov rcx, rdx ; n
|
|
||||||
mov rax, rdi ; t
|
|
||||||
mov rdx, rsi ; w
|
|
||||||
add rcx, 7 ; n/8 rounding up
|
|
||||||
and rcx, -8
|
|
||||||
sub rax, 16
|
|
||||||
sub rdx, 16
|
|
||||||
jz .done
|
|
||||||
align 16
|
|
||||||
.loop: ; each iteration adjusts 8 weights
|
|
||||||
movdqa xmm2, [rdx+rcx*2] ; w[i]
|
|
||||||
movdqa xmm3, [rax+rcx*2] ; t[i]
|
|
||||||
paddsw xmm3, xmm3 ; t[i]*2
|
|
||||||
pmulhw xmm3, xmm0 ; t[i]*err*2 >> 16
|
|
||||||
paddsw xmm3, xmm1 ; (t[i]*err*2 >> 16)+1
|
|
||||||
psraw xmm3, 1 ; (t[i]*err*2 >> 16)+1 >> 1
|
|
||||||
paddsw xmm2, xmm3 ; w[i] + xmm3
|
|
||||||
movdqa [rdx+rcx*2], xmm2
|
|
||||||
sub rcx, 8
|
|
||||||
ja .loop
|
|
||||||
.done:
|
|
||||||
ret
|
|
||||||
|
|
||||||
140
paq7asm.asm
140
paq7asm.asm
@ -1,140 +0,0 @@
|
|||||||
; NASM assembly language code for PAQ7.
|
|
||||||
; (C) 2005, Matt Mahoney.
|
|
||||||
; This is free software under GPL, http://www.gnu.org/licenses/gpl.txt
|
|
||||||
;
|
|
||||||
; MINGW g++: nasm paq7asm.asm -f win32 --prefix _
|
|
||||||
; DJGPP g++: nasm paq7asm.asm -f coff --prefix _
|
|
||||||
; Borland, Mars: nasm paq7asm.asm -f obj --prefix _
|
|
||||||
; Linux: nasm paq7asm.asm -f elf
|
|
||||||
;
|
|
||||||
; For other Windows compilers try -f win32 or -f obj. Some old versions
|
|
||||||
; of Linux should use -f aout instead of -f elf.
|
|
||||||
;
|
|
||||||
; This code will only work on a Pentium-MMX or higher. It doesn't
|
|
||||||
; use extended (Katmai/SSE) instructions. It won't work
|
|
||||||
; in 64-bit mode.
|
|
||||||
|
|
||||||
section .text use32 class=CODE
|
|
||||||
|
|
||||||
; Reset after MMX
|
|
||||||
global do_emms
|
|
||||||
do_emms:
|
|
||||||
emms
|
|
||||||
ret
|
|
||||||
|
|
||||||
; Vector product a*b of n signed words, returning signed dword scaled
|
|
||||||
; down by 8 bits. n is rounded up to a multiple of 8.
|
|
||||||
|
|
||||||
global dot_product ; (short* a, short* b, int n)
|
|
||||||
align 16
|
|
||||||
dot_product:
|
|
||||||
mov eax, [esp+4] ; a
|
|
||||||
mov edx, [esp+8] ; b
|
|
||||||
mov ecx, [esp+12] ; n
|
|
||||||
add ecx, 7 ; n rounding up
|
|
||||||
and ecx, -8
|
|
||||||
jz .done
|
|
||||||
sub eax, 8
|
|
||||||
sub edx, 8
|
|
||||||
pxor mm0, mm0 ; sum = 0
|
|
||||||
.loop: ; each loop sums 4 products
|
|
||||||
movq mm1, [eax+ecx*2] ; put halves of vector product in mm0
|
|
||||||
pmaddwd mm1, [edx+ecx*2]
|
|
||||||
movq mm2, [eax+ecx*2-8]
|
|
||||||
pmaddwd mm2, [edx+ecx*2-8]
|
|
||||||
psrad mm1, 8
|
|
||||||
psrad mm2, 8
|
|
||||||
paddd mm0, mm1
|
|
||||||
paddd mm0, mm2
|
|
||||||
sub ecx, 8
|
|
||||||
ja .loop
|
|
||||||
movq mm1, mm0 ; add 2 halves of mm0 and return in eax
|
|
||||||
psrlq mm1, 32
|
|
||||||
paddd mm0, mm1
|
|
||||||
movd eax, mm0
|
|
||||||
emms
|
|
||||||
.done
|
|
||||||
ret
|
|
||||||
|
|
||||||
; This should work on a Pentium 4 or higher in 32-bit mode,
|
|
||||||
; but it isn't much faster than the MMX version so I don't use it.
|
|
||||||
|
|
||||||
global dot_product_sse2 ; (short* a, short* b, int n)
|
|
||||||
align 16
|
|
||||||
dot_product_sse2:
|
|
||||||
mov eax, [esp+4] ; a
|
|
||||||
mov edx, [esp+8] ; b
|
|
||||||
mov ecx, [esp+12] ; n
|
|
||||||
add ecx, 7 ; n rounding up
|
|
||||||
and ecx, -8
|
|
||||||
jz .done
|
|
||||||
sub eax, 16
|
|
||||||
sub edx, 16
|
|
||||||
pxor xmm0, xmm0 ; sum = 0
|
|
||||||
.loop: ; each loop sums 4 products
|
|
||||||
movdqa xmm1, [eax+ecx*2] ; put parital sums of vector product in xmm0
|
|
||||||
pmaddwd xmm1, [edx+ecx*2]
|
|
||||||
psrad xmm1, 8
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
sub ecx, 8
|
|
||||||
ja .loop
|
|
||||||
movdqa xmm1, xmm0 ; add 4 parts of xmm0 and return in eax
|
|
||||||
psrldq xmm1, 8
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
movdqa xmm1, xmm0
|
|
||||||
psrldq xmm1, 4
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
movd eax, xmm0
|
|
||||||
.done
|
|
||||||
ret
|
|
||||||
|
|
||||||
|
|
||||||
; Train n neural network weights w[n] on inputs t[n] and err.
|
|
||||||
; w[i] += t[i]*err*2+1 >> 17 bounded to +- 32K.
|
|
||||||
; n is rounded up to a multiple of 8.
|
|
||||||
|
|
||||||
global train ; (short* t, short* w, int n, int err)
|
|
||||||
align 16
|
|
||||||
train:
|
|
||||||
mov eax, [esp+16] ; err
|
|
||||||
and eax, 0xffff ; put 4 copies of err in mm0
|
|
||||||
movd mm0, eax
|
|
||||||
movd mm1, eax
|
|
||||||
psllq mm1, 16
|
|
||||||
por mm0, mm1
|
|
||||||
movq mm1, mm0
|
|
||||||
psllq mm1, 32
|
|
||||||
por mm0, mm1
|
|
||||||
pcmpeqb mm1, mm1 ; 4 copies of 1 in mm1
|
|
||||||
psrlw mm1, 15
|
|
||||||
mov eax, [esp+4] ; t
|
|
||||||
mov edx, [esp+8] ; w
|
|
||||||
mov ecx, [esp+12] ; n
|
|
||||||
add ecx, 7 ; n/8 rounding up
|
|
||||||
and ecx, -8
|
|
||||||
sub eax, 8
|
|
||||||
sub edx, 8
|
|
||||||
jz .done
|
|
||||||
.loop: ; each iteration adjusts 8 weights
|
|
||||||
movq mm2, [edx+ecx*2] ; w[i]
|
|
||||||
movq mm3, [eax+ecx*2] ; t[i]
|
|
||||||
movq mm4, [edx+ecx*2-8] ; w[i]
|
|
||||||
movq mm5, [eax+ecx*2-8] ; t[i]
|
|
||||||
paddsw mm3, mm3
|
|
||||||
paddsw mm5, mm5
|
|
||||||
pmulhw mm3, mm0
|
|
||||||
pmulhw mm5, mm0
|
|
||||||
paddsw mm3, mm1
|
|
||||||
paddsw mm5, mm1
|
|
||||||
psraw mm3, 1
|
|
||||||
psraw mm5, 1
|
|
||||||
paddsw mm2, mm3
|
|
||||||
paddsw mm4, mm5
|
|
||||||
movq [edx+ecx*2], mm2
|
|
||||||
movq [edx+ecx*2-8], mm4
|
|
||||||
sub ecx, 8
|
|
||||||
ja .loop
|
|
||||||
.done:
|
|
||||||
emms
|
|
||||||
ret
|
|
||||||
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
; NASM assembly language code for PAQ7.
|
|
||||||
; (C) 2005, Matt Mahoney.
|
|
||||||
; train - written by wowtiger, Jan. 30, 2007
|
|
||||||
;
|
|
||||||
; This is free software under GPL, http://www.gnu.org/licenses/gpl.txt
|
|
||||||
;
|
|
||||||
; This code is a replacement for paq7asm.asm for newer processors
|
|
||||||
; supporting SSE2 instructions. It is about 1% faster than the
|
|
||||||
; equivalent MMX code. It can be linked with any version of paq7*
|
|
||||||
; or paq8*. Assemble as below, then link following the instructions
|
|
||||||
; in the C++ source code, replacing paq7asm.obj with paq7asmsse.obj.
|
|
||||||
; No C++ code changes are needed.
|
|
||||||
;
|
|
||||||
; MINGW g++: nasm paq7asmsse.asm -f win32 --prefix _
|
|
||||||
; DJGPP g++: nasm paq7asmsse.asm -f coff --prefix _
|
|
||||||
; Borland, Mars: nasm paq7asmsse.asm -f obj --prefix _
|
|
||||||
; Linux: nasm paq7asmsse.asm -f elf
|
|
||||||
;
|
|
||||||
|
|
||||||
section .text use32 class=CODE
|
|
||||||
|
|
||||||
; Vector product a*b of n signed words, returning signed dword scaled
|
|
||||||
; down by 8 bits. n is rounded up to a multiple of 8.
|
|
||||||
|
|
||||||
global dot_product ; (short* a, short* b, int n)
|
|
||||||
align 16
|
|
||||||
dot_product:
|
|
||||||
mov eax, [esp+4] ; a
|
|
||||||
mov edx, [esp+8] ; b
|
|
||||||
mov ecx, [esp+12] ; n
|
|
||||||
add ecx, 7 ; n rounding up
|
|
||||||
and ecx, -8
|
|
||||||
jz .done
|
|
||||||
sub eax, 16
|
|
||||||
sub edx, 16
|
|
||||||
pxor xmm0, xmm0 ; sum = 0
|
|
||||||
.loop: ; each loop sums 4 products
|
|
||||||
movdqa xmm1, [eax+ecx*2] ; put parital sums of vector product in xmm0
|
|
||||||
pmaddwd xmm1, [edx+ecx*2]
|
|
||||||
psrad xmm1, 8
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
sub ecx, 8
|
|
||||||
ja .loop
|
|
||||||
movdqa xmm1, xmm0 ; add 4 parts of xmm0 and return in eax
|
|
||||||
psrldq xmm1, 8
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
movdqa xmm1, xmm0
|
|
||||||
psrldq xmm1, 4
|
|
||||||
paddd xmm0, xmm1
|
|
||||||
movd eax, xmm0
|
|
||||||
.done
|
|
||||||
ret
|
|
||||||
|
|
||||||
|
|
||||||
; Train n neural network weights w[n] on inputs t[n] and err.
|
|
||||||
; w[i] += t[i]*err*2+1 >> 17 bounded to +- 32K.
|
|
||||||
; n is rounded up to a multiple of 8.
|
|
||||||
|
|
||||||
; Train for SSE2
|
|
||||||
; Use this code to get some performance...
|
|
||||||
|
|
||||||
global train ; (short* t, short* w, int n, int err)
|
|
||||||
align 16
|
|
||||||
train:
|
|
||||||
mov eax, [esp+4] ; t
|
|
||||||
mov edx, [esp+8] ; w
|
|
||||||
mov ecx, [esp+12] ; n
|
|
||||||
add ecx, 7 ; n/8 rounding up
|
|
||||||
and ecx, -8
|
|
||||||
jz .done
|
|
||||||
sub eax, 16
|
|
||||||
sub edx, 16
|
|
||||||
movd xmm0, [esp+16]
|
|
||||||
pshuflw xmm0,xmm0,0
|
|
||||||
punpcklqdq xmm0,xmm0
|
|
||||||
.loop: ; each iteration adjusts 8 weights
|
|
||||||
movdqa xmm3, [eax+ecx*2] ; t[i]
|
|
||||||
movdqa xmm2, [edx+ecx*2] ; w[i]
|
|
||||||
paddsw xmm3, xmm3 ; t[i]*2
|
|
||||||
pmulhw xmm3, xmm0 ; t[i]*err*2 >> 16
|
|
||||||
paddsw xmm3, [_mask] ; (t[i]*err*2 >> 16)+1
|
|
||||||
psraw xmm3, 1 ; (t[i]*err*2 >> 16)+1 >> 1
|
|
||||||
paddsw xmm2, xmm3 ; w[i] + xmm3
|
|
||||||
movdqa [edx+ecx*2], xmm2
|
|
||||||
sub ecx, 8
|
|
||||||
ja .loop
|
|
||||||
.done:
|
|
||||||
ret
|
|
||||||
|
|
||||||
align 16
|
|
||||||
_mask dd 10001h,10001h,10001h,10001h ; 8 copies of 1 in xmm1
|
|
||||||
|
|
||||||
|
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
0
pypaqtest.paq
Executable file → Normal file
0
pypaqtest.paq
Executable file → Normal file
1
python-src/.python-version
Normal file
1
python-src/.python-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
430
Fbrowser.py → python-src/Fbrowser.py
Executable file → Normal file
430
Fbrowser.py → python-src/Fbrowser.py
Executable file → Normal file
@ -1,216 +1,216 @@
|
|||||||
# Path: Fbrowser.py
|
# Path: Fbrowser.py
|
||||||
# Sample Music Browser & Ogranizer: Main.py
|
# Sample Music Browser & Ogranizer: Main.py
|
||||||
|
|
||||||
# Importing Libraries
|
# Importing Libraries
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from ScanOrg import organizer, file_scanner, DirectoryFilterProxyModel, FileFilterProxyModel
|
from ScanOrg import organizer, file_scanner, DirectoryFilterProxyModel, FileFilterProxyModel
|
||||||
|
|
||||||
from PyQt5.QtGui import QStandardItem , QStandardItemModel
|
from PyQt5.QtGui import QStandardItem , QStandardItemModel
|
||||||
from PyQt5.QtWidgets import QApplication, QLabel, QPushButton, QTreeView, QMessageBox, QSlider, QWidget, QFileSystemModel, QSplitter, QHBoxLayout, QFileDialog
|
from PyQt5.QtWidgets import QApplication, QLabel, QPushButton, QTreeView, QMessageBox, QSlider, QWidget, QFileSystemModel, QSplitter, QHBoxLayout, QFileDialog
|
||||||
from PyQt5.QtMultimedia import QMediaPlaylist, QMediaPlayer, QMediaContent, QAudioFormat, QAudioDeviceInfo, QAudio
|
from PyQt5.QtMultimedia import QMediaPlaylist, QMediaPlayer, QMediaContent, QAudioFormat, QAudioDeviceInfo, QAudio
|
||||||
from PyQt5.QtCore import QDir, QSortFilterProxyModel, Qt, QUrl #QAbstractItemModel, QAbstractProxyModel, QModelIndex, QItemSelectionModel, QItemSelection, QItemSelectionRange, QItemSelectionModel, QItemSelection, QItemSelectionRange
|
from PyQt5.QtCore import QDir, QSortFilterProxyModel, Qt, QUrl #QAbstractItemModel, QAbstractProxyModel, QModelIndex, QItemSelectionModel, QItemSelection, QItemSelectionRange, QItemSelectionModel, QItemSelection, QItemSelectionRange
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Audio Format
|
# Audio Format
|
||||||
audio_format = QAudioFormat()
|
audio_format = QAudioFormat()
|
||||||
audio_format.setSampleRate(44100)
|
audio_format.setSampleRate(44100)
|
||||||
audio_format.setChannelCount(2)
|
audio_format.setChannelCount(2)
|
||||||
audio_format.setSampleSize(16)
|
audio_format.setSampleSize(16)
|
||||||
audio_format.setCodec('audio/pcm')
|
audio_format.setCodec('audio/pcm')
|
||||||
audio_format.setByteOrder(QAudioFormat.LittleEndian)
|
audio_format.setByteOrder(QAudioFormat.LittleEndian)
|
||||||
audio_format.setSampleType(QAudioFormat.SignedInt)
|
audio_format.setSampleType(QAudioFormat.SignedInt)
|
||||||
# Audio Device Info
|
# Audio Device Info
|
||||||
device_info = QAudioDeviceInfo.defaultOutputDevice()
|
device_info = QAudioDeviceInfo.defaultOutputDevice()
|
||||||
if not device_info.isFormatSupported(audio_format):
|
if not device_info.isFormatSupported(audio_format):
|
||||||
print('Raw audio format not supported by backend, cannot play audio.')
|
print('Raw audio format not supported by backend, cannot play audio.')
|
||||||
audio_format = device_info.nearestFormat(audio_format)
|
audio_format = device_info.nearestFormat(audio_format)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Sample Music Browser Main Class
|
# Sample Music Browser Main Class
|
||||||
class SampleMusicBrowser(QWidget):
|
class SampleMusicBrowser(QWidget):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.organizer = organizer()
|
self.organizer = organizer()
|
||||||
self.file_model = QStandardItemModel()
|
self.file_model = QStandardItemModel()
|
||||||
self.player = QMediaPlayer()
|
self.player = QMediaPlayer()
|
||||||
self.playlist = QMediaPlaylist()
|
self.playlist = QMediaPlaylist()
|
||||||
self.player.setPlaylist(self.playlist)
|
self.player.setPlaylist(self.playlist)
|
||||||
self.tree_model = QFileSystemModel()
|
self.tree_model = QFileSystemModel()
|
||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
|
|
||||||
self.folder_contents_view.setEditTriggers(QTreeView.NoEditTriggers)
|
self.folder_contents_view.setEditTriggers(QTreeView.NoEditTriggers)
|
||||||
self.player.error.connect(self.player_error)
|
self.player.error.connect(self.player_error)
|
||||||
self.player.mediaStatusChanged.connect(self.player_media_status_changed)
|
self.player.mediaStatusChanged.connect(self.player_media_status_changed)
|
||||||
self.player.setAudioRole(QAudio.MusicRole)
|
self.player.setAudioRole(QAudio.MusicRole)
|
||||||
|
|
||||||
def player_error(self, error):
|
def player_error(self, error):
|
||||||
if error == QMediaPlayer.NoError:
|
if error == QMediaPlayer.NoError:
|
||||||
return
|
return
|
||||||
print('Error: ' + self.player.errorString())
|
print('Error: ' + self.player.errorString())
|
||||||
|
|
||||||
def player_media_status_changed(self, status):
|
def player_media_status_changed(self, status):
|
||||||
if status == QMediaPlayer.NoMedia:
|
if status == QMediaPlayer.NoMedia:
|
||||||
return
|
return
|
||||||
print('Media Status: ' + str(status))
|
print('Media Status: ' + str(status))
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
layout = QHBoxLayout()
|
layout = QHBoxLayout()
|
||||||
label = QLabel('Sample Music Browser')
|
label = QLabel('Sample Music Browser')
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
button = QPushButton('Exit')
|
button = QPushButton('Exit')
|
||||||
button.clicked.connect(self.show_exit_popup)
|
button.clicked.connect(self.show_exit_popup)
|
||||||
layout.addWidget(button)
|
layout.addWidget(button)
|
||||||
self.file_tree = QTreeView()
|
self.file_tree = QTreeView()
|
||||||
self.file_tree.setHeaderHidden(True)
|
self.file_tree.setHeaderHidden(True)
|
||||||
self.file_tree.clicked.connect(self.change_directory)
|
self.file_tree.clicked.connect(self.change_directory)
|
||||||
self.folder_contents_view = QTreeView()
|
self.folder_contents_view = QTreeView()
|
||||||
self.folder_contents_view.setHeaderHidden(False)
|
self.folder_contents_view.setHeaderHidden(False)
|
||||||
self.folder_contents_view.setRootIsDecorated(False)
|
self.folder_contents_view.setRootIsDecorated(False)
|
||||||
self.folder_contents_view.setSortingEnabled(True)
|
self.folder_contents_view.setSortingEnabled(True)
|
||||||
splitter = QSplitter()
|
splitter = QSplitter()
|
||||||
splitter.addWidget(self.file_tree)
|
splitter.addWidget(self.file_tree)
|
||||||
splitter.addWidget(self.folder_contents_view)
|
splitter.addWidget(self.folder_contents_view)
|
||||||
layout.addWidget(splitter)
|
layout.addWidget(splitter)
|
||||||
self.current_dir_label = QLabel()
|
self.current_dir_label = QLabel()
|
||||||
layout.addWidget(self.current_dir_label)
|
layout.addWidget(self.current_dir_label)
|
||||||
up_dir_button = QPushButton('Up Directory')
|
up_dir_button = QPushButton('Up Directory')
|
||||||
up_dir_button.clicked.connect(self.go_up_directory)
|
up_dir_button.clicked.connect(self.go_up_directory)
|
||||||
layout.addWidget(up_dir_button)
|
layout.addWidget(up_dir_button)
|
||||||
back_button = QPushButton('Back')
|
back_button = QPushButton('Back')
|
||||||
back_button.clicked.connect(self.go_back_directory)
|
back_button.clicked.connect(self.go_back_directory)
|
||||||
layout.addWidget(back_button)
|
layout.addWidget(back_button)
|
||||||
forward_button = QPushButton('Forward')
|
forward_button = QPushButton('Forward')
|
||||||
forward_button.clicked.connect(self.go_forward_directory)
|
forward_button.clicked.connect(self.go_forward_directory)
|
||||||
layout.addWidget(forward_button)
|
layout.addWidget(forward_button)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
self.setWindowTitle('Samples are life!')
|
self.setWindowTitle('Samples are life!')
|
||||||
path = QFileDialog.getExistingDirectory(self, 'Select Directory')
|
path = QFileDialog.getExistingDirectory(self, 'Select Directory')
|
||||||
if path:
|
if path:
|
||||||
self.populate_file_tree(path)
|
self.populate_file_tree(path)
|
||||||
play_button = QPushButton('Play')
|
play_button = QPushButton('Play')
|
||||||
play_button.clicked.connect(self.player.play)
|
play_button.clicked.connect(self.player.play)
|
||||||
layout.addWidget(play_button)
|
layout.addWidget(play_button)
|
||||||
pause_button = QPushButton('Pause')
|
pause_button = QPushButton('Pause')
|
||||||
pause_button.clicked.connect(self.player.pause)
|
pause_button.clicked.connect(self.player.pause)
|
||||||
layout.addWidget(pause_button)
|
layout.addWidget(pause_button)
|
||||||
stop_button = QPushButton('Stop')
|
stop_button = QPushButton('Stop')
|
||||||
stop_button.clicked.connect(self.player.stop)
|
stop_button.clicked.connect(self.player.stop)
|
||||||
layout.addWidget(stop_button)
|
layout.addWidget(stop_button)
|
||||||
self.player.stateChanged.connect(self.player_state_changed)
|
self.player.stateChanged.connect(self.player_state_changed)
|
||||||
self.player.positionChanged.connect(self.player_position_changed)
|
self.player.positionChanged.connect(self.player_position_changed)
|
||||||
self.player.durationChanged.connect(self.player_duration_changed)
|
self.player.durationChanged.connect(self.player_duration_changed)
|
||||||
self.player.setVolume(50)
|
self.player.setVolume(50)
|
||||||
volume_slider = QSlider(Qt.Horizontal)
|
volume_slider = QSlider(Qt.Horizontal)
|
||||||
volume_slider.setRange(0, 100)
|
volume_slider.setRange(0, 100)
|
||||||
volume_slider.setValue(50)
|
volume_slider.setValue(50)
|
||||||
volume_slider.valueChanged.connect(self.player.setVolume)
|
volume_slider.valueChanged.connect(self.player.setVolume)
|
||||||
layout.addWidget(volume_slider)
|
layout.addWidget(volume_slider)
|
||||||
self.playlist.currentIndexChanged.connect(self.playlist_current_index_changed)
|
self.playlist.currentIndexChanged.connect(self.playlist_current_index_changed)
|
||||||
self.playlist.currentMediaChanged.connect(self.playlist_current_media_changed)
|
self.playlist.currentMediaChanged.connect(self.playlist_current_media_changed)
|
||||||
self.playlist.mediaInserted.connect(self.playlist_media_inserted)
|
self.playlist.mediaInserted.connect(self.playlist_media_inserted)
|
||||||
self.playlist.mediaRemoved.connect(self.playlist_media_removed)
|
self.playlist.mediaRemoved.connect(self.playlist_media_removed)
|
||||||
self.playlist.setPlaybackMode(QMediaPlaylist.Loop)
|
self.playlist.setPlaybackMode(QMediaPlaylist.Loop)
|
||||||
self.folder_contents_view.doubleClicked.connect(self.play_file)
|
self.folder_contents_view.doubleClicked.connect(self.play_file)
|
||||||
|
|
||||||
def directory_loaded(self, path):
|
def directory_loaded(self, path):
|
||||||
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.model.index(path)))
|
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.model.index(path)))
|
||||||
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
|
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
|
||||||
|
|
||||||
def populate_file_tree(self, path):
|
def populate_file_tree(self, path):
|
||||||
try:
|
try:
|
||||||
self.tree_model.setRootPath(path)
|
self.tree_model.setRootPath(path)
|
||||||
self.file_tree.setModel(self.tree_model)
|
self.file_tree.setModel(self.tree_model)
|
||||||
self.directory_model = DirectoryFilterProxyModel()
|
self.directory_model = DirectoryFilterProxyModel()
|
||||||
self.directory_model.setSourceModel(self.tree_model)
|
self.directory_model.setSourceModel(self.tree_model)
|
||||||
self.file_tree.setModel(self.directory_model)
|
self.file_tree.setModel(self.directory_model)
|
||||||
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.tree_model.index(path)))
|
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.tree_model.index(path)))
|
||||||
self.list_model = QFileSystemModel()
|
self.list_model = QFileSystemModel()
|
||||||
self.list_model.setRootPath(path)
|
self.list_model.setRootPath(path)
|
||||||
self.file_filter_model = FileFilterProxyModel()
|
self.file_filter_model = FileFilterProxyModel()
|
||||||
self.file_filter_model.setSourceModel(self.list_model)
|
self.file_filter_model.setSourceModel(self.list_model)
|
||||||
self.folder_contents_view.setModel(self.file_filter_model)
|
self.folder_contents_view.setModel(self.file_filter_model)
|
||||||
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
|
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
|
||||||
self.current_dir_label.setText(path)
|
self.current_dir_label.setText(path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error Populating File Tree: {e}")
|
print(f"Error Populating File Tree: {e}")
|
||||||
|
|
||||||
|
|
||||||
def show_exit_popup(self):
|
def show_exit_popup(self):
|
||||||
reply = QMessageBox.question(self, 'Exit', 'Are you sure you want to exit?',
|
reply = QMessageBox.question(self, 'Exit', 'Are you sure you want to exit?',
|
||||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
def play_file(self, index):
|
def play_file(self, index):
|
||||||
index = self.file_filter_model.mapToSource(index)
|
index = self.file_filter_model.mapToSource(index)
|
||||||
file_path = self.list_model.filePath(index)
|
file_path = self.list_model.filePath(index)
|
||||||
media = QMediaContent(QUrl.fromLocalFile(file_path))
|
media = QMediaContent(QUrl.fromLocalFile(file_path))
|
||||||
self.playlist.addMedia(media)
|
self.playlist.addMedia(media)
|
||||||
self.player.play()
|
self.player.play()
|
||||||
|
|
||||||
def player_state_changed(self, state):
|
def player_state_changed(self, state):
|
||||||
if state == QMediaPlayer.StoppedState:
|
if state == QMediaPlayer.StoppedState:
|
||||||
self.playlist.setCurrentIndex(0)
|
self.playlist.setCurrentIndex(0)
|
||||||
|
|
||||||
def player_position_changed(self, position):
|
def player_position_changed(self, position):
|
||||||
pass
|
pass
|
||||||
def player_duration_changed(self, duration):
|
def player_duration_changed(self, duration):
|
||||||
pass
|
pass
|
||||||
def playlist_current_index_changed(self, index):
|
def playlist_current_index_changed(self, index):
|
||||||
pass
|
pass
|
||||||
def playlist_current_media_changed(self, media):
|
def playlist_current_media_changed(self, media):
|
||||||
pass
|
pass
|
||||||
def playlist_media_inserted(self, start, end):
|
def playlist_media_inserted(self, start, end):
|
||||||
pass
|
pass
|
||||||
def playlist_media_removed(self, start, end):
|
def playlist_media_removed(self, start, end):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def change_directory(self, index):
|
def change_directory(self, index):
|
||||||
index = self.directory_model.mapToSource(index)
|
index = self.directory_model.mapToSource(index)
|
||||||
try:
|
try:
|
||||||
file_path = self.tree_model.filePath(index)
|
file_path = self.tree_model.filePath(index)
|
||||||
self.list_model.setRootPath(file_path)
|
self.list_model.setRootPath(file_path)
|
||||||
self.current_dir_label.setText(file_path)
|
self.current_dir_label.setText(file_path)
|
||||||
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(file_path)))
|
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(file_path)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error Changing Dirs.: {e}")
|
print(f"Error Changing Dirs.: {e}")
|
||||||
|
|
||||||
def go_up_directory(self):
|
def go_up_directory(self):
|
||||||
index = self.folder_contents_view.rootIndex()
|
index = self.folder_contents_view.rootIndex()
|
||||||
index = self.file_filter_model.mapToSource(index)
|
index = self.file_filter_model.mapToSource(index)
|
||||||
index = self.file_model.index(index)
|
index = self.file_model.index(index)
|
||||||
index = index.parent()
|
index = index.parent()
|
||||||
index = self.file_filter_model.mapFromSource(index)
|
index = self.file_filter_model.mapFromSource(index)
|
||||||
self.folder_contents_view.setRootIndex(index)
|
self.folder_contents_view.setRootIndex(index)
|
||||||
self.current_dir_label.setText(self.model.filePath(index))
|
self.current_dir_label.setText(self.model.filePath(index))
|
||||||
|
|
||||||
def go_back_directory(self):
|
def go_back_directory(self):
|
||||||
index = self.folder_contents_view.rootIndex()
|
index = self.folder_contents_view.rootIndex()
|
||||||
index = self.file_filter_model.mapToSource(index)
|
index = self.file_filter_model.mapToSource(index)
|
||||||
index = self.file_model.index(index)
|
index = self.file_model.index(index)
|
||||||
index = index.parent()
|
index = index.parent()
|
||||||
index = self.file_filter_model.mapFromSource(index)
|
index = self.file_filter_model.mapFromSource(index)
|
||||||
self.folder_contents_view.setRootIndex(index)
|
self.folder_contents_view.setRootIndex(index)
|
||||||
self.current_dir_label.setText(self.model.filePath(index))
|
self.current_dir_label.setText(self.model.filePath(index))
|
||||||
|
|
||||||
def go_forward_directory(self):
|
def go_forward_directory(self):
|
||||||
index = self.folder_contents_view.rootIndex()
|
index = self.folder_contents_view.rootIndex()
|
||||||
index = self.file_filter_model.mapToSource(index)
|
index = self.file_filter_model.mapToSource(index)
|
||||||
index = self.file_model.index(index)
|
index = self.file_model.index(index)
|
||||||
index = index.parent()
|
index = index.parent()
|
||||||
index = self.file_filter_model.mapFromSource(index)
|
index = self.file_filter_model.mapFromSource(index)
|
||||||
self.folder_contents_view.setRootIndex(index)
|
self.folder_contents_view.setRootIndex(index)
|
||||||
self.current_dir_label.setText(self.model.filePath(index))
|
self.current_dir_label.setText(self.model.filePath(index))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
sampleMusicBrowser = SampleMusicBrowser()
|
sampleMusicBrowser = SampleMusicBrowser()
|
||||||
sampleMusicBrowser.show()
|
sampleMusicBrowser.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
520
MidPlay.py → python-src/MidPlay.py
Executable file → Normal file
520
MidPlay.py → python-src/MidPlay.py
Executable file → Normal file
@ -1,261 +1,261 @@
|
|||||||
#Path: MidPlay.py
|
#Path: MidPlay.py
|
||||||
# Description: A class to play MIDI files and a class to view MIDI files
|
# Description: A class to play MIDI files and a class to view MIDI files
|
||||||
|
|
||||||
import pygame
|
import pygame
|
||||||
# Imports
|
# Imports
|
||||||
import mido
|
import mido
|
||||||
import fluidsynth
|
import fluidsynth
|
||||||
import os
|
import os
|
||||||
from PyQt5.QtWidgets import (QApplication, QLabel, QListWidget, QFileDialog, QMessageBox, QWidget, QPushButton, QHBoxLayout,
|
from PyQt6.QtWidgets import (QApplication, QLabel, QListWidget, QFileDialog, QMessageBox, QWidget, QPushButton, QHBoxLayout,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
QSlider) # structured for readability and to avoid long lines and it annoys my friend XD
|
QSlider) # structured for readability and to avoid long lines and it annoys my friend XD
|
||||||
from PyQt5.QtCore import QTimer, Qt
|
from PyQt6.QtCore import QTimer, Qt
|
||||||
import threading
|
import threading
|
||||||
import cProfile # profiler remove for production
|
import cProfile # profiler remove for production
|
||||||
|
|
||||||
#profiler remove for production
|
#profiler remove for production
|
||||||
def start_profiling():
|
def start_profiling():
|
||||||
global pr
|
global pr
|
||||||
pr = cProfile.Profile()
|
pr = cProfile.Profile()
|
||||||
pr.enable()
|
pr.enable()
|
||||||
|
|
||||||
def stop_profiling():
|
def stop_profiling():
|
||||||
pr.disable()
|
pr.disable()
|
||||||
pr.dump_stats('midi_profile.out')
|
pr.dump_stats('midi_profile.out')
|
||||||
|
|
||||||
# The pygame.mixer.init() call is necessary to initialize the mixer module
|
# The pygame.mixer.init() call is necessary to initialize the mixer module
|
||||||
# before any sound can be played. The pygame.init() call is necessary maybe.
|
# before any sound can be played. The pygame.init() call is necessary maybe.
|
||||||
pygame.mixer.init()
|
pygame.mixer.init()
|
||||||
pygame.init()
|
pygame.init()
|
||||||
|
|
||||||
class MidPlayGUI(QWidget):
|
class MidPlayGUI(QWidget):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.player = MidPlay()
|
self.player = MidPlay()
|
||||||
self.current_midi_label = QLabel()
|
self.current_midi_label = QLabel()
|
||||||
self.playlist_widget = QListWidget()
|
self.playlist_widget = QListWidget()
|
||||||
self.setWindowTitle("MidPlay - Midi Player")
|
self.setWindowTitle("MidPlay - Midi Player")
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.timer = QTimer()
|
self.timer = QTimer()
|
||||||
self.timer.timeout.connect(self.handle_song_end)
|
self.timer.timeout.connect(self.handle_song_end)
|
||||||
self.timer.start(1000)
|
self.timer.start(1000)
|
||||||
|
|
||||||
def set_volume(self, value):
|
def set_volume(self, value):
|
||||||
volume = value / 100
|
volume = value / 100
|
||||||
pygame.mixer.music.set_volume(volume)
|
pygame.mixer.music.set_volume(volume)
|
||||||
|
|
||||||
def update_progress(self):
|
def update_progress(self):
|
||||||
if self.player.current_midi:
|
if self.player.current_midi:
|
||||||
current_time = pygame.mixer.music.get_pos() / 1000 # get_pos returns time in milliseconds NOT SECONDS!
|
current_time = pygame.mixer.music.get_pos() / 1000 # get_pos returns time in milliseconds NOT SECONDS!
|
||||||
total_time = self.calculate_midi_duration(self.player.current_midi)
|
total_time = self.calculate_midi_duration(self.player.current_midi)
|
||||||
progress = current_time / total_time * 100
|
progress = current_time / total_time * 100
|
||||||
self.progress_bar.setValue(int(progress))
|
self.progress_bar.setValue(int(progress))
|
||||||
|
|
||||||
def calculate_midi_duration(self, midi_file):
|
def calculate_midi_duration(self, midi_file):
|
||||||
total_duration = 0
|
total_duration = 0
|
||||||
for track in midi_file.tracks:
|
for track in midi_file.tracks:
|
||||||
track_duration = max([msg.time for msg in track]) if track else 0
|
track_duration = max([msg.time for msg in track]) if track else 0
|
||||||
total_duration = max(total_duration, track_duration)
|
total_duration = max(total_duration, track_duration)
|
||||||
return total_duration
|
return total_duration
|
||||||
|
|
||||||
def handle_song_end(self):
|
def handle_song_end(self):
|
||||||
if self.player.playing and not pygame.mixer.music.get_busy():
|
if self.player.playing and not pygame.mixer.music.get_busy():
|
||||||
self.player.next_song()
|
self.player.next_song()
|
||||||
if self.player.playlist:
|
if self.player.playlist:
|
||||||
self.player.current_index %= len(self.player.playlist)
|
self.player.current_index %= len(self.player.playlist)
|
||||||
filepath = self.player.playlist[self.player.current_index]
|
filepath = self.player.playlist[self.player.current_index]
|
||||||
filename = os.path.basename(filepath)
|
filename = os.path.basename(filepath)
|
||||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
self.update_progress()
|
self.update_progress()
|
||||||
|
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
#label = QLabel("MidPlay - Midi player")
|
#label = QLabel("MidPlay - Midi player")
|
||||||
#label.setStyleSheet("font-size: 20px; font-weight: bold;")
|
#label.setStyleSheet("font-size: 20px; font-weight: bold;")
|
||||||
|
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
self.volume_slider = QSlider(Qt.Horizontal)
|
self.volume_slider = QSlider(Qt.Horizontal)
|
||||||
self.volume_slider.setMinimum(0)
|
self.volume_slider.setMinimum(0)
|
||||||
self.volume_slider.setMaximum(100)
|
self.volume_slider.setMaximum(100)
|
||||||
self.volume_slider.setValue(100)
|
self.volume_slider.setValue(100)
|
||||||
self.volume_slider.valueChanged.connect(self.set_volume)
|
self.volume_slider.valueChanged.connect(self.set_volume)
|
||||||
self.current_midi_label.setText("Current MIDI: None")
|
self.current_midi_label.setText("Current MIDI: None")
|
||||||
|
|
||||||
|
|
||||||
self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song)
|
self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song)
|
||||||
pygame.mixer.music.set_endevent(pygame.USEREVENT)
|
pygame.mixer.music.set_endevent(pygame.USEREVENT)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
play_button = QPushButton("Play")
|
play_button = QPushButton("Play")
|
||||||
play_button.clicked.connect(self.player.play_midi)
|
play_button.clicked.connect(self.player.play_midi)
|
||||||
|
|
||||||
pause_button = QPushButton("Pause")
|
pause_button = QPushButton("Pause")
|
||||||
pause_button.clicked.connect(self.player.pause)
|
pause_button.clicked.connect(self.player.pause)
|
||||||
|
|
||||||
stop_button = QPushButton("Stop")
|
stop_button = QPushButton("Stop")
|
||||||
stop_button.clicked.connect(self.player.stop)
|
stop_button.clicked.connect(self.player.stop)
|
||||||
|
|
||||||
next_button = QPushButton("Next")
|
next_button = QPushButton("Next")
|
||||||
next_button.clicked.connect(self.player.next_song)
|
next_button.clicked.connect(self.player.next_song)
|
||||||
|
|
||||||
back_button = QPushButton("Back")
|
back_button = QPushButton("Back")
|
||||||
back_button.clicked.connect(self.previous_song)
|
back_button.clicked.connect(self.previous_song)
|
||||||
|
|
||||||
add_button = QPushButton("Add to Playlist")
|
add_button = QPushButton("Add to Playlist")
|
||||||
add_button.clicked.connect(self.load_midi_file)
|
add_button.clicked.connect(self.load_midi_file)
|
||||||
|
|
||||||
add_folder_button = QPushButton("Add Folder to Playlist")
|
add_folder_button = QPushButton("Add Folder to Playlist")
|
||||||
add_folder_button.clicked.connect(self.load_folder)
|
add_folder_button.clicked.connect(self.load_folder)
|
||||||
|
|
||||||
clear_button = QPushButton("Clear Playlist")
|
clear_button = QPushButton("Clear Playlist")
|
||||||
clear_button.clicked.connect(self.clear_playlist)
|
clear_button.clicked.connect(self.clear_playlist)
|
||||||
|
|
||||||
# Window layout
|
# Window layout
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.addWidget(self.current_midi_label)
|
layout.addWidget(self.current_midi_label)
|
||||||
layout.addWidget(self.playlist_widget)
|
layout.addWidget(self.playlist_widget)
|
||||||
layout.addWidget(self.progress_bar)
|
layout.addWidget(self.progress_bar)
|
||||||
layout.addWidget(self.volume_slider)
|
layout.addWidget(self.volume_slider)
|
||||||
layout.addWidget(play_button)
|
layout.addWidget(play_button)
|
||||||
layout.addWidget(pause_button)
|
layout.addWidget(pause_button)
|
||||||
layout.addWidget(stop_button)
|
layout.addWidget(stop_button)
|
||||||
layout.addWidget(next_button)
|
layout.addWidget(next_button)
|
||||||
layout.addWidget(back_button)
|
layout.addWidget(back_button)
|
||||||
layout.addWidget(add_button)
|
layout.addWidget(add_button)
|
||||||
layout.addWidget(add_folder_button)
|
layout.addWidget(add_folder_button)
|
||||||
layout.addWidget(clear_button)
|
layout.addWidget(clear_button)
|
||||||
|
|
||||||
progress_volume_layout = QHBoxLayout()
|
progress_volume_layout = QHBoxLayout()
|
||||||
progress_volume_layout.addWidget(self.progress_bar)
|
progress_volume_layout.addWidget(self.progress_bar)
|
||||||
progress_volume_layout.addWidget(self.volume_slider)
|
progress_volume_layout.addWidget(self.volume_slider)
|
||||||
layout.addLayout(progress_volume_layout)
|
layout.addLayout(progress_volume_layout)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
# Event handlers
|
# Event handlers
|
||||||
def play_selected_song(self, item):
|
def play_selected_song(self, item):
|
||||||
index = self.playlist_widget.row(item)
|
index = self.playlist_widget.row(item)
|
||||||
self.current_index = index
|
self.current_index = index
|
||||||
filepath = self.player.playlist[self.current_index]
|
filepath = self.player.playlist[self.current_index]
|
||||||
self.player.load_midi(filepath)
|
self.player.load_midi(filepath)
|
||||||
self.player.play_midi()
|
self.player.play_midi()
|
||||||
pygame.mixer.music.set_endevent(pygame.USEREVENT)
|
pygame.mixer.music.set_endevent(pygame.USEREVENT)
|
||||||
filename = os.path.basename(filepath)
|
filename = os.path.basename(filepath)
|
||||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
|
|
||||||
def load_midi_file(self):
|
def load_midi_file(self):
|
||||||
filepath, _ = QFileDialog.getOpenFileName(self, "Select MIDI File", filter="MIDI files (*.mid *.midi)")
|
filepath, _ = QFileDialog.getOpenFileName(self, "Select MIDI File", filter="MIDI files (*.mid *.midi)")
|
||||||
if filepath:
|
if filepath:
|
||||||
filename = os.path.basename(filepath)
|
filename = os.path.basename(filepath)
|
||||||
self.player.load_midi(filepath)
|
self.player.load_midi(filepath)
|
||||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
self.player.play_midi()
|
self.player.play_midi()
|
||||||
self.playlist_widget.addItem(filename)
|
self.playlist_widget.addItem(filename)
|
||||||
self.player.add_to_playlist(filepath)
|
self.player.add_to_playlist(filepath)
|
||||||
|
|
||||||
def load_folder(self):
|
def load_folder(self):
|
||||||
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
|
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
|
||||||
if folder:
|
if folder:
|
||||||
for file in os.listdir(folder):
|
for file in os.listdir(folder):
|
||||||
if file.endswith((".midi", ".mid")):
|
if file.endswith((".midi", ".mid")):
|
||||||
filepath = os.path.join(folder, file)
|
filepath = os.path.join(folder, file)
|
||||||
self.playlist_widget.addItem(file)
|
self.playlist_widget.addItem(file)
|
||||||
self.player.add_to_playlist(filepath) # Only add to playlist, don't load immediately!!!!!!!!!!!!!!!
|
self.player.add_to_playlist(filepath) # Only add to playlist, don't load immediately!!!!!!!!!!!!!!!
|
||||||
|
|
||||||
#probably should be in the MidPlay class
|
#probably should be in the MidPlay class
|
||||||
|
|
||||||
|
|
||||||
def previous_song(self):
|
def previous_song(self):
|
||||||
if self.player.playlist:
|
if self.player.playlist:
|
||||||
filepath = self.player.playlist[self.current_index]
|
filepath = self.player.playlist[self.current_index]
|
||||||
filename = os.path.basename(filepath)
|
filename = os.path.basename(filepath)
|
||||||
self.player.current_index = (self.player.current_index - 1) % len(self.player.playlist)
|
self.player.current_index = (self.player.current_index - 1) % len(self.player.playlist)
|
||||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
self.player.play_midi()
|
self.player.play_midi()
|
||||||
|
|
||||||
def clear_playlist(self):
|
def clear_playlist(self):
|
||||||
self.player.clear_playlist()
|
self.player.clear_playlist()
|
||||||
self.playlist_widget.clear()
|
self.playlist_widget.clear()
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
confirmation = QMessageBox.question(self, "Exit Confirmation", "Are you sure you want to exit?", QMessageBox.Yes | QMessageBox.No)
|
confirmation = QMessageBox.question(self, "Exit Confirmation", "Are you sure you want to exit?", QMessageBox.Yes | QMessageBox.No)
|
||||||
if confirmation == QMessageBox.Yes:
|
if confirmation == QMessageBox.Yes:
|
||||||
pygame.mixer.quit()
|
pygame.mixer.quit()
|
||||||
pygame.quit()
|
pygame.quit()
|
||||||
event.accept()
|
event.accept()
|
||||||
else:
|
else:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
|
||||||
class MidPlay:
|
class MidPlay:
|
||||||
"""The Heart of Midi Playback"""
|
"""The Heart of Midi Playback"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.playlist = []
|
self.playlist = []
|
||||||
self.current_midi = None
|
self.current_midi = None
|
||||||
self.playing = False
|
self.playing = False
|
||||||
self.current_index = 0
|
self.current_index = 0
|
||||||
|
|
||||||
def load_midi(self, filepath: str) -> None:
|
def load_midi(self, filepath: str) -> None:
|
||||||
def load():
|
def load():
|
||||||
try:
|
try:
|
||||||
self.current_midi = mido.MidiFile(filepath)
|
self.current_midi = mido.MidiFile(filepath)
|
||||||
pygame.mixer.music.load(filepath)
|
pygame.mixer.music.load(filepath)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading MIDI: {e}")
|
print(f"Error loading MIDI: {e}")
|
||||||
threading.Thread(target=load).start()
|
threading.Thread(target=load).start()
|
||||||
|
|
||||||
def add_to_playlist(self, filepath: str) -> None:
|
def add_to_playlist(self, filepath: str) -> None:
|
||||||
self.playlist.append(filepath)
|
self.playlist.append(filepath)
|
||||||
|
|
||||||
def clear_playlist(self) -> None:
|
def clear_playlist(self) -> None:
|
||||||
self.playlist = []
|
self.playlist = []
|
||||||
|
|
||||||
def play_midi(self) -> None:
|
def play_midi(self) -> None:
|
||||||
def play():
|
def play():
|
||||||
if self.current_midi:
|
if self.current_midi:
|
||||||
self.current_midi.instruments[0].synthesize()
|
self.current_midi.instruments[0].synthesize()
|
||||||
pygame.mixer.music.play()
|
pygame.mixer.music.play()
|
||||||
self.playing = True
|
self.playing = True
|
||||||
pygame.mixer.music.set_endevent(pygame.USEREVENT)
|
pygame.mixer.music.set_endevent(pygame.USEREVENT)
|
||||||
else:
|
else:
|
||||||
print("No MIDI file loaded")
|
print("No MIDI file loaded")
|
||||||
threading.Thread(target=play).start()
|
threading.Thread(target=play).start()
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
pygame.mixer.music.pause()
|
pygame.mixer.music.pause()
|
||||||
self.playing = False
|
self.playing = False
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
pygame.mixer.music.stop()
|
pygame.mixer.music.stop()
|
||||||
self.playing = False
|
self.playing = False
|
||||||
|
|
||||||
|
|
||||||
def next_song(self) -> None:
|
def next_song(self) -> None:
|
||||||
#print("Debug: next_song() called", self.playlist) debug line
|
#print("Debug: next_song() called", self.playlist) debug line
|
||||||
if self.playlist:
|
if self.playlist:
|
||||||
self.current_index = (self.current_index + 1) % len(self.playlist)
|
self.current_index = (self.current_index + 1) % len(self.playlist)
|
||||||
filepath = self.playlist[self.current_index]
|
filepath = self.playlist[self.current_index]
|
||||||
|
|
||||||
# If a new MIDI was loaded before the last one ended, respect that as the new playlist start
|
# If a new MIDI was loaded before the last one ended, respect that as the new playlist start
|
||||||
if self.current_midi and self.playing:
|
if self.current_midi and self.playing:
|
||||||
# print("Debug: New MIDI loaded before last one ended") # debug line
|
# print("Debug: New MIDI loaded before last one ended") # debug line
|
||||||
self.load_midi(filepath)
|
self.load_midi(filepath)
|
||||||
self.play_midi()
|
self.play_midi()
|
||||||
# print("Debug: Filepath:", filepath) # debug line
|
# print("Debug: Filepath:", filepath) # debug line
|
||||||
# print("Debug: Current MIDI:", self.current_midi) # debug line
|
# print("Debug: Current MIDI:", self.current_midi) # debug line
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
player_gui = MidPlayGUI()
|
player_gui = MidPlayGUI()
|
||||||
player_gui.show()
|
player_gui.show()
|
||||||
running = True
|
running = True
|
||||||
while True:
|
while True:
|
||||||
for event in pygame.event.get():
|
for event in pygame.event.get():
|
||||||
if event.type == pygame.USEREVENT:
|
if event.type == pygame.USEREVENT:
|
||||||
player_gui.player.next_song()
|
player_gui.player.next_song()
|
||||||
if event.type == pygame.QUIT:
|
if event.type == pygame.QUIT:
|
||||||
running = False
|
running = False
|
||||||
break
|
break
|
||||||
app.exec_()
|
app.exec_()
|
||||||
"""
|
"""
|
||||||
372
ScanOrg.py → python-src/ScanOrg.py
Executable file → Normal file
372
ScanOrg.py → python-src/ScanOrg.py
Executable file → Normal file
@ -1,187 +1,187 @@
|
|||||||
#Path: ScanOrg.py
|
#Path: ScanOrg.py
|
||||||
# Description: A class to scan and organize music files
|
# Description: A class to scan and organize music files
|
||||||
|
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
import zipfile
|
import zipfile
|
||||||
import py7zr
|
import py7zr
|
||||||
import rarfile
|
import rarfile
|
||||||
import os
|
import os
|
||||||
import mutagen
|
import mutagen
|
||||||
from PyQt6.QtCore import Qt, QSortFilterProxyModel, pyqtSignal
|
from PyQt6.QtCore import Qt, QSortFilterProxyModel, pyqtSignal
|
||||||
|
|
||||||
# Directory Filter Proxy Model
|
# Directory Filter Proxy Model
|
||||||
class DirectoryFilterProxyModel(QSortFilterProxyModel):
|
class DirectoryFilterProxyModel(QSortFilterProxyModel):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
self.setFilterKeyColumn(0)
|
self.setFilterKeyColumn(0)
|
||||||
def filterAcceptsRow(self, source_row, source_parent):
|
def filterAcceptsRow(self, source_row, source_parent):
|
||||||
index = self.sourceModel().index(source_row, 0, source_parent)
|
index = self.sourceModel().index(source_row, 0, source_parent)
|
||||||
return self.sourceModel().isDir(index)
|
return self.sourceModel().isDir(index)
|
||||||
|
|
||||||
# File Filter Proxy Model
|
# File Filter Proxy Model
|
||||||
class FileFilterProxyModel(QSortFilterProxyModel):
|
class FileFilterProxyModel(QSortFilterProxyModel):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
self.setFilterKeyColumn(0)
|
self.setFilterKeyColumn(0)
|
||||||
self.allowed_extensions = ['.zip', '.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a']
|
self.allowed_extensions = ['.zip', '.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a']
|
||||||
|
|
||||||
def filterAcceptsRow(self, source_row, source_parent):
|
def filterAcceptsRow(self, source_row, source_parent):
|
||||||
index = self.sourceModel().index(source_row, 0, source_parent)
|
index = self.sourceModel().index(source_row, 0, source_parent)
|
||||||
if self.sourceModel().isDir(index):
|
if self.sourceModel().isDir(index):
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return self.sourceModel().fileName(index).endswith(tuple(self.allowed_extensions))
|
return self.sourceModel().fileName(index).endswith(tuple(self.allowed_extensions))
|
||||||
|
|
||||||
# File Scan and Organize
|
# File Scan and Organize
|
||||||
class file_scanner:
|
class file_scanner:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.file_list = []
|
self.file_list = []
|
||||||
self.cache = {}
|
self.cache = {}
|
||||||
|
|
||||||
def scan(self, path):
|
def scan(self, path):
|
||||||
def background_scan(self, path):
|
def background_scan(self, path):
|
||||||
if path in self.cache:
|
if path in self.cache:
|
||||||
return self.cache[path]
|
return self.cache[path]
|
||||||
|
|
||||||
file_list = []
|
file_list = []
|
||||||
dirs_queue = queue.Queue()
|
dirs_queue = queue.Queue()
|
||||||
dirs_queue.put(path)
|
dirs_queue.put(path)
|
||||||
|
|
||||||
while not dirs_queue.empty():
|
while not dirs_queue.empty():
|
||||||
current_path = dirs_queue.get()
|
current_path = dirs_queue.get()
|
||||||
try:
|
try:
|
||||||
for root, dirs, files in os.walk(current_path):
|
for root, dirs, files in os.walk(current_path):
|
||||||
for dir in dirs:
|
for dir in dirs:
|
||||||
dirs_queue.put(os.path.join(root, dir))
|
dirs_queue.put(os.path.join(root, dir))
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.endswith(('.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a')):
|
if file.endswith(('.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a')):
|
||||||
file_list.append(os.path.join(root, file))
|
file_list.append(os.path.join(root, file))
|
||||||
self.cache[current_path] = file_list
|
self.cache[current_path] = file_list
|
||||||
except (IOError, PermissionError, FileNotFoundError, OSError) as e:
|
except (IOError, PermissionError, FileNotFoundError, OSError) as e:
|
||||||
print(f"Error Scanning Files: {e}")
|
print(f"Error Scanning Files: {e}")
|
||||||
|
|
||||||
return file_list
|
return file_list
|
||||||
|
|
||||||
file_list = []
|
file_list = []
|
||||||
thread = threading.Thread(target=background_scan, args=(path, file_list))
|
thread = threading.Thread(target=background_scan, args=(path, file_list))
|
||||||
thread.start()
|
thread.start()
|
||||||
return file_list
|
return file_list
|
||||||
|
|
||||||
def get_file_list(self):
|
def get_file_list(self):
|
||||||
return self.file_list
|
return self.file_list
|
||||||
|
|
||||||
def clear_file_list(self):
|
def clear_file_list(self):
|
||||||
self.file_list = []
|
self.file_list = []
|
||||||
|
|
||||||
class extractor:
|
class extractor:
|
||||||
def zipviewer(self, index, file_filter_model, list_model, extraction_directory):
|
def zipviewer(self, index, file_filter_model, list_model, extraction_directory):
|
||||||
if index.isValid() and extraction_directory is not None:
|
if index.isValid() and extraction_directory is not None:
|
||||||
index = file_filter_model.mapToSource(index)
|
index = file_filter_model.mapToSource(index)
|
||||||
file_path = list_model.filePath(index)
|
file_path = list_model.filePath(index)
|
||||||
try:
|
try:
|
||||||
if file_path.endswith(('.zip', '.rar', '.7z')):
|
if file_path.endswith(('.zip', '.rar', '.7z')):
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||||
for filename in zip_ref.namelist():
|
for filename in zip_ref.namelist():
|
||||||
destination = os.path.join(extraction_directory, filename)
|
destination = os.path.join(extraction_directory, filename)
|
||||||
zip_ref.extract(filename, extraction_directory)
|
zip_ref.extract(filename, extraction_directory)
|
||||||
except (zipfile.BadZipFile, OSError, zipfile.LargeZipFile, zipfile.LargeZipFile) as e:
|
except (zipfile.BadZipFile, OSError, zipfile.LargeZipFile, zipfile.LargeZipFile) as e:
|
||||||
print(f"Extraction Error: {e}")
|
print(f"Extraction Error: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
class organizer:
|
class organizer:
|
||||||
global metadata_queue
|
global metadata_queue
|
||||||
metadata_queue = queue.Queue()
|
metadata_queue = queue.Queue()
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.file_list = []
|
self.file_list = []
|
||||||
self.artist_list = []
|
self.artist_list = []
|
||||||
self.album_list = []
|
self.album_list = []
|
||||||
self.genre_list = []
|
self.genre_list = []
|
||||||
self.year_list = []
|
self.year_list = []
|
||||||
self.file_scanner = file_scanner()
|
self.file_scanner = file_scanner()
|
||||||
self.file_info_cache = {}
|
self.file_info_cache = {}
|
||||||
|
|
||||||
def scan(self, path):
|
def scan(self, path):
|
||||||
if path in self.file_scanner.cache:
|
if path in self.file_scanner.cache:
|
||||||
self.file_list = self.file_scanner.cache[path]
|
self.file_list = self.file_scanner.cache[path]
|
||||||
else:
|
else:
|
||||||
self.file_list = self.file_scanner.scan(path)
|
self.file_list = self.file_scanner.scan(path)
|
||||||
|
|
||||||
def get_file_list(self):
|
def get_file_list(self):
|
||||||
return self.file_list
|
return self.file_list
|
||||||
def clear_file_list(self):
|
def clear_file_list(self):
|
||||||
self.file_list = []
|
self.file_list = []
|
||||||
def get_artist_list(self):
|
def get_artist_list(self):
|
||||||
return self.artist_list
|
return self.artist_list
|
||||||
def get_album_list(self):
|
def get_album_list(self):
|
||||||
return self.album_list
|
return self.album_list
|
||||||
def get_genre_list(self):
|
def get_genre_list(self):
|
||||||
return self.genre_list
|
return self.genre_list
|
||||||
def get_year_list(self):
|
def get_year_list(self):
|
||||||
return self.year_list
|
return self.year_list
|
||||||
def clear_artist_list(self):
|
def clear_artist_list(self):
|
||||||
self.artist_list = []
|
self.artist_list = []
|
||||||
def clear_album_list(self):
|
def clear_album_list(self):
|
||||||
self.album_list = []
|
self.album_list = []
|
||||||
def clear_genre_list(self):
|
def clear_genre_list(self):
|
||||||
self.genre_list = []
|
self.genre_list = []
|
||||||
def clear_year_list(self):
|
def clear_year_list(self):
|
||||||
self.year_list = []
|
self.year_list = []
|
||||||
|
|
||||||
def organize(self):
|
def organize(self):
|
||||||
results_queue = queue.Queue()
|
results_queue = queue.Queue()
|
||||||
metadata = pyqtSignal(dict)
|
metadata = pyqtSignal(dict)
|
||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
futures = []
|
futures = []
|
||||||
for file in self.file_list:
|
for file in self.file_list:
|
||||||
futures.append(executor.submit(self.get_file_info, file, results_queue))
|
futures.append(executor.submit(self.get_file_info, file, results_queue))
|
||||||
|
|
||||||
for future in concurrent.futures.as_completed(futures):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
try:
|
try:
|
||||||
metadata = future.result()
|
metadata = future.result()
|
||||||
if metadata['artist'] not in self.artist_list:
|
if metadata['artist'] not in self.artist_list:
|
||||||
self.artist_list.append(metadata['artist'])
|
self.artist_list.append(metadata['artist'])
|
||||||
if metadata['album'] not in self.album_list:
|
if metadata['album'] not in self.album_list:
|
||||||
self.album_list.append(metadata['album'])
|
self.album_list.append(metadata['album'])
|
||||||
if metadata['genre'] not in self.genre_list:
|
if metadata['genre'] not in self.genre_list:
|
||||||
self.genre_list.append(metadata['genre'])
|
self.genre_list.append(metadata['genre'])
|
||||||
if metadata['year'] not in self.year_list:
|
if metadata['year'] not in self.year_list:
|
||||||
self.year_list.append(metadata['year'])
|
self.year_list.append(metadata['year'])
|
||||||
except mutagen.mp3.HeaderNotFoundError:
|
except mutagen.mp3.HeaderNotFoundError:
|
||||||
print('Error: ' + file)
|
print('Error: ' + file)
|
||||||
continue
|
continue
|
||||||
while not metadata_queue.put(metadata):
|
while not metadata_queue.put(metadata):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_file_info(self, file, results_queue):
|
def get_file_info(self, file, results_queue):
|
||||||
try:
|
try:
|
||||||
audio = mutagen.File(file)
|
audio = mutagen.File(file)
|
||||||
artist = audio['artist'][0]
|
artist = audio['artist'][0]
|
||||||
album = audio['album'][0]
|
album = audio['album'][0]
|
||||||
genre = audio['genre'][0]
|
genre = audio['genre'][0]
|
||||||
year = audio['date'][0]
|
year = audio['date'][0]
|
||||||
if artist not in self.artist_list:
|
if artist not in self.artist_list:
|
||||||
self.artist_list.append(artist)
|
self.artist_list.append(artist)
|
||||||
if album not in self.album_list:
|
if album not in self.album_list:
|
||||||
self.album_list.append(album)
|
self.album_list.append(album)
|
||||||
if genre not in self.genre_list:
|
if genre not in self.genre_list:
|
||||||
self.genre_list.append(genre)
|
self.genre_list.append(genre)
|
||||||
if year not in self.year_list:
|
if year not in self.year_list:
|
||||||
self.year_list.append(year)
|
self.year_list.append(year)
|
||||||
metadata = {
|
metadata = {
|
||||||
'artist': artist,
|
'artist': artist,
|
||||||
'album': album,
|
'album': album,
|
||||||
'genre': genre,
|
'genre': genre,
|
||||||
'year': year
|
'year': year
|
||||||
}
|
}
|
||||||
self.metadata_extracted.emit(metadata)
|
self.metadata_extracted.emit(metadata)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results_queue.put(None)
|
results_queue.put(None)
|
||||||
print('Error: ' + file)
|
print('Error: ' + file)
|
||||||
if os.path.splitext(file)[1] == ('.mp3', '.wav', '.flac', '.m4a', '.wma', 'mid', '.midi'):
|
if os.path.splitext(file)[1] == ('.mp3', '.wav', '.flac', '.m4a', '.wma', 'mid', '.midi'):
|
||||||
self.organize_audio()
|
self.organize_audio()
|
||||||
audio = mutagen.File(file)
|
audio = mutagen.File(file)
|
||||||
0
compression.py → python-src/archive_compression.py
Executable file → Normal file
0
compression.py → python-src/archive_compression.py
Executable file → Normal file
0
extraction.py → python-src/extraction.py
Executable file → Normal file
0
extraction.py → python-src/extraction.py
Executable file → Normal file
0
paqtest.py → python-src/paqtest.py
Executable file → Normal file
0
paqtest.py → python-src/paqtest.py
Executable file → Normal file
18
readme.txt → python-src/readme.txt
Executable file → Normal file
18
readme.txt → python-src/readme.txt
Executable file → Normal file
@ -1,9 +1,9 @@
|
|||||||
<<<<<<< HEAD
|
<<<<<<< HEAD
|
||||||
FBroswer is a sample and loop organizer and browser with extra features
|
FBroswer is a sample and loop organizer and browser with extra features
|
||||||
such as the ability to sample that audio you're checking out
|
such as the ability to sample that audio you're checking out
|
||||||
full midi play back support with soundfont controls
|
full midi play back support with soundfont controls
|
||||||
a timer to help balance work life and personal life
|
a timer to help balance work life and personal life
|
||||||
a way to keep track of what you're listening to
|
a way to keep track of what you're listening to
|
||||||
=======
|
=======
|
||||||
fbrowser is a sample and loops audio organizer and browser
|
fbrowser is a sample and loops audio organizer and browser
|
||||||
>>>>>>> 1ee9caf82243dd45a72a97bf6c5de681139670e2
|
>>>>>>> 1ee9caf82243dd45a72a97bf6c5de681139670e2
|
||||||
@ -1,13 +1,12 @@
|
|||||||
PyQT5
|
PyQT5
|
||||||
PyQt5-tools
|
|
||||||
PyQt6
|
PyQt6
|
||||||
PyQt6-tools
|
|
||||||
matplotlib
|
matplotlib
|
||||||
rarfile
|
rarfile
|
||||||
py7zr
|
py7zr
|
||||||
pygame
|
pygame
|
||||||
mido
|
mido
|
||||||
|
mutagen
|
||||||
numpy
|
numpy
|
||||||
crypto
|
crypto
|
||||||
django
|
django
|
||||||
pyFluidSynth
|
pyFluidSynth
|
||||||
0
stanzip.py → python-src/stanzip.py
Executable file → Normal file
0
stanzip.py → python-src/stanzip.py
Executable file → Normal file
336
test.py → python-src/test.py
Executable file → Normal file
336
test.py → python-src/test.py
Executable file → Normal file
@ -1,168 +1,168 @@
|
|||||||
import mido as pretty_midi
|
import mido as pretty_midi
|
||||||
import random
|
import random
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, filedialog
|
from tkinter import ttk, filedialog
|
||||||
import pygame
|
import pygame
|
||||||
import pypianoroll # type: ignore
|
import pypianoroll # type: ignore
|
||||||
from icecream import ic # type: ignore
|
from icecream import ic # type: ignore
|
||||||
|
|
||||||
class midgen:
|
class midgen:
|
||||||
|
|
||||||
def __init__(self, status_label: ttk.Label):
|
def __init__(self, status_label: ttk.Label):
|
||||||
self.status_label = status_label
|
self.status_label = status_label
|
||||||
self.scales = self.scales()
|
self.scales = self.scales()
|
||||||
|
|
||||||
def scales(self):
|
def scales(self):
|
||||||
scales = {
|
scales = {
|
||||||
"Major": [0, 2, 4, 5, 7, 9, 11],
|
"Major": [0, 2, 4, 5, 7, 9, 11],
|
||||||
"Minor": [0, 2, 3, 5, 7, 8, 10],
|
"Minor": [0, 2, 3, 5, 7, 8, 10],
|
||||||
"Pentatonic": [0, 2, 4, 7, 9],
|
"Pentatonic": [0, 2, 4, 7, 9],
|
||||||
"Blues": [0, 3, 5, 6, 7, 10],
|
"Blues": [0, 3, 5, 6, 7, 10],
|
||||||
"Whole Tone": [0, 2, 4, 6, 8, 10],
|
"Whole Tone": [0, 2, 4, 6, 8, 10],
|
||||||
"Chromatic": [i for i in range(12)],
|
"Chromatic": [i for i in range(12)],
|
||||||
"Octatonic": [0, 1, 3, 4, 6, 7, 9, 10],
|
"Octatonic": [0, 1, 3, 4, 6, 7, 9, 10],
|
||||||
"Harmonic Minor": [0, 2, 3, 5, 7, 8, 11],
|
"Harmonic Minor": [0, 2, 3, 5, 7, 8, 11],
|
||||||
"Melodic Minor": [0, 2, 3, 5, 7, 9, 11],
|
"Melodic Minor": [0, 2, 3, 5, 7, 9, 11],
|
||||||
"Dorian": [0, 2, 3, 5, 7, 9, 10],
|
"Dorian": [0, 2, 3, 5, 7, 9, 10],
|
||||||
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
|
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
|
||||||
"Lydian": [0, 2, 4, 6, 7, 9, 11],
|
"Lydian": [0, 2, 4, 6, 7, 9, 11],
|
||||||
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
|
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
|
||||||
"Locrian": [0, 1, 3, 5, 6, 8, 10],
|
"Locrian": [0, 1, 3, 5, 6, 8, 10],
|
||||||
"Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
|
"Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
|
||||||
"Whole Half Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
|
"Whole Half Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
|
||||||
"Arabian": [0, 2, 4, 5, 6, 8, 10],
|
"Arabian": [0, 2, 4, 5, 6, 8, 10],
|
||||||
"Hungarian Minor": [0, 2, 3, 6, 7, 8, 11],
|
"Hungarian Minor": [0, 2, 3, 6, 7, 8, 11],
|
||||||
"Enigmatic": [0, 1, 4, 6, 8, 10, 11],
|
"Enigmatic": [0, 1, 4, 6, 8, 10, 11],
|
||||||
"Neapolitan Major": [0, 1, 3, 5, 7, 9, 11],
|
"Neapolitan Major": [0, 1, 3, 5, 7, 9, 11],
|
||||||
"Neapolitan Minor": [0, 1, 3, 5, 7, 8, 11],
|
"Neapolitan Minor": [0, 1, 3, 5, 7, 8, 11],
|
||||||
"Bluesy": [0, 3, 5, 6, 7, 10],
|
"Bluesy": [0, 3, 5, 6, 7, 10],
|
||||||
"Hawaiian": [0, 2, 3, 7, 9],
|
"Hawaiian": [0, 2, 3, 7, 9],
|
||||||
"Japanese": [0, 1, 5, 7, 8],
|
"Japanese": [0, 1, 5, 7, 8],
|
||||||
"Chinese": [0, 4, 6, 7, 11],
|
"Chinese": [0, 4, 6, 7, 11],
|
||||||
"Gypsy": [0, 2, 3, 6, 7, 8, 10],
|
"Gypsy": [0, 2, 3, 6, 7, 8, 10],
|
||||||
"Hirojoshi": [0, 2, 3, 7, 8],
|
"Hirojoshi": [0, 2, 3, 7, 8],
|
||||||
"In Sen": [0, 1, 5, 7, 10],
|
"In Sen": [0, 1, 5, 7, 10],
|
||||||
"Iwato": [0, 1, 5, 6, 10],
|
"Iwato": [0, 1, 5, 6, 10],
|
||||||
"Kumoi": [0, 2, 3, 7, 9],
|
"Kumoi": [0, 2, 3, 7, 9],
|
||||||
"Pelog": [0, 1, 3, 7, 8],
|
"Pelog": [0, 1, 3, 7, 8],
|
||||||
"Ryukyu": [0, 4, 5, 7, 11],
|
"Ryukyu": [0, 4, 5, 7, 11],
|
||||||
"Spanish": [0, 1, 3, 4, 5, 6, 8, 10],
|
"Spanish": [0, 1, 3, 4, 5, 6, 8, 10],
|
||||||
"Todi": [0, 1, 3, 6, 7, 8, 11],
|
"Todi": [0, 1, 3, 6, 7, 8, 11],
|
||||||
"Yo": [0, 2, 5, 7, 9]
|
"Yo": [0, 2, 5, 7, 9]
|
||||||
}
|
}
|
||||||
return scales
|
return scales
|
||||||
|
|
||||||
|
|
||||||
def generate_midi(self):
|
def generate_midi(self):
|
||||||
self.status_label.config(text='Generating MIDI...')
|
self.status_label.config(text='Generating MIDI...')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
midi = pretty_midi.PrettyMIDI()
|
midi = pretty_midi.PrettyMIDI()
|
||||||
instrument = pretty_midi.Instrument(0)
|
instrument = pretty_midi.Instrument(0)
|
||||||
|
|
||||||
scale = random.choice(list(self.scales.keys()))
|
scale = random.choice(list(self.scales.keys()))
|
||||||
scale_notes = self.scales[scale]
|
scale_notes = self.scales[scale]
|
||||||
ic(f"Using scale: {scale}")
|
ic(f"Using scale: {scale}")
|
||||||
ic(f"Using notes: {scale_notes}")
|
ic(f"Using notes: {scale_notes}")
|
||||||
|
|
||||||
for start, end in zip(range(0, 100, 10), range(10, 110, 10)):
|
for start, end in zip(range(0, 100, 10), range(10, 110, 10)):
|
||||||
note = pretty_midi.Note(
|
note = pretty_midi.Note(
|
||||||
velocity=100, pitch=random.choice(scale_notes),
|
velocity=100, pitch=random.choice(scale_notes),
|
||||||
start=start, end=end
|
start=start, end=end
|
||||||
)
|
)
|
||||||
instrument.notes.append(note)
|
instrument.notes.append(note)
|
||||||
|
|
||||||
midi.instruments.append(instrument)
|
midi.instruments.append(instrument)
|
||||||
|
|
||||||
filepath = filedialog.asksaveasfilename(defaultextension='.mid')
|
filepath = filedialog.asksaveasfilename(defaultextension='.mid')
|
||||||
if filepath:
|
if filepath:
|
||||||
midi.write(filepath)
|
midi.write(filepath)
|
||||||
track = pypianoroll.Multitrack(filepath)
|
track = pypianoroll.Multitrack(filepath)
|
||||||
track.plot()
|
track.plot()
|
||||||
self.status_label.config(text='MIDI generated successfully!')
|
self.status_label.config(text='MIDI generated successfully!')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_label.config(text=f"Error generating MIDI: {e}")
|
self.status_label.config(text=f"Error generating MIDI: {e}")
|
||||||
|
|
||||||
class MidPlay:
|
class MidPlay:
|
||||||
"""A class to handle MIDI file playback."""
|
"""A class to handle MIDI file playback."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.playlist = []
|
self.playlist = []
|
||||||
self.current_midi = None
|
self.current_midi = None
|
||||||
self.playing = False
|
self.playing = False
|
||||||
pygame.mixer.init()
|
pygame.mixer.init()
|
||||||
|
|
||||||
def load_midi(self, filepath: str) -> None:
|
def load_midi(self, filepath: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.current_midi = pretty_midi.PrettyMIDI(filepath)
|
self.current_midi = pretty_midi.PrettyMIDI(filepath)
|
||||||
pygame.mixer.music.load(filepath)
|
pygame.mixer.music.load(filepath)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading MIDI: {e}")
|
print(f"Error loading MIDI: {e}")
|
||||||
|
|
||||||
def add_to_playlist(self, filepath: str) -> None:
|
def add_to_playlist(self, filepath: str) -> None:
|
||||||
"""Adds a MIDI file to the playlist.
|
"""Adds a MIDI file to the playlist.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath: The path to the MIDI file.
|
filepath: The path to the MIDI file.
|
||||||
"""
|
"""
|
||||||
self.playlist.append(filepath)
|
self.playlist.append(filepath)
|
||||||
|
|
||||||
def clear_playlist(self) -> None:
|
def clear_playlist(self) -> None:
|
||||||
"""Clears the playlist."""
|
"""Clears the playlist."""
|
||||||
self.playlist = []
|
self.playlist = []
|
||||||
|
|
||||||
def play_midi(self) -> None:
|
def play_midi(self) -> None:
|
||||||
"""Starts or resumes playback of the current MIDI file."""
|
"""Starts or resumes playback of the current MIDI file."""
|
||||||
if self.current_midi:
|
if self.current_midi:
|
||||||
self.current_midi.instruments[0].synthesize()
|
self.current_midi.instruments[0].synthesize()
|
||||||
pygame.mixer.music.play()
|
pygame.mixer.music.play()
|
||||||
self.playing = True
|
self.playing = True
|
||||||
else:
|
else:
|
||||||
print("No MIDI file loaded")
|
print("No MIDI file loaded")
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
"""Pauses playback."""
|
"""Pauses playback."""
|
||||||
pygame.mixer.music.pause()
|
pygame.mixer.music.pause()
|
||||||
self.playing = False
|
self.playing = False
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stops playback."""
|
"""Stops playback."""
|
||||||
pygame.mixer.music.stop()
|
pygame.mixer.music.stop()
|
||||||
self.playing = False
|
self.playing = False
|
||||||
|
|
||||||
class UserInterface:
|
class UserInterface:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.root = tk.Tk()
|
self.root = tk.Tk()
|
||||||
self.root.title("MIDI Generator")
|
self.root.title("MIDI Generator")
|
||||||
self.root.geometry("400x200")
|
self.root.geometry("400x200")
|
||||||
self.root.resizable(True, True)
|
self.root.resizable(True, True)
|
||||||
self.status_label = ttk.Label(self.root, text="")
|
self.status_label = ttk.Label(self.root, text="")
|
||||||
self.status_label.pack()
|
self.status_label.pack()
|
||||||
|
|
||||||
self.midi_generator = midgen(self.status_label)
|
self.midi_generator = midgen(self.status_label)
|
||||||
self.midi_player = MidPlay()
|
self.midi_player = MidPlay()
|
||||||
|
|
||||||
|
|
||||||
self.filepath = None
|
self.filepath = None
|
||||||
self.midi = None
|
self.midi = None
|
||||||
|
|
||||||
|
|
||||||
self.generate_button = ttk.Button(self.root, text="Generate MIDI", command=self.midi_generator.generate_midi)
|
self.generate_button = ttk.Button(self.root, text="Generate MIDI", command=self.midi_generator.generate_midi)
|
||||||
self.generate_button.pack()
|
self.generate_button.pack()
|
||||||
|
|
||||||
self.load_button = ttk.Button(self.root, text="Load MIDI", command=lambda: self.midi_player.load_midi(self.filepath))
|
self.load_button = ttk.Button(self.root, text="Load MIDI", command=lambda: self.midi_player.load_midi(self.filepath))
|
||||||
self.load_button.pack()
|
self.load_button.pack()
|
||||||
|
|
||||||
self.play_button = ttk.Button(self.root, text="Play MIDI", command=lambda: self.midi_player.play_midi())
|
self.play_button = ttk.Button(self.root, text="Play MIDI", command=lambda: self.midi_player.play_midi())
|
||||||
self.play_button.pack()
|
self.play_button.pack()
|
||||||
|
|
||||||
self.exit_button = ttk.Button(self.root, text="Exit", command=self.root.quit)
|
self.exit_button = ttk.Button(self.root, text="Exit", command=self.root.quit)
|
||||||
self.exit_button.pack()
|
self.exit_button.pack()
|
||||||
|
|
||||||
window = tk.Tk()
|
window = tk.Tk()
|
||||||
window.title("MIDI Generator")
|
window.title("MIDI Generator")
|
||||||
self.root.mainloop()
|
self.root.mainloop()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
ui = UserInterface()
|
ui = UserInterface()
|
||||||
72
test_Fbrowser.py → python-src/test_Fbrowser.py
Executable file → Normal file
72
test_Fbrowser.py → python-src/test_Fbrowser.py
Executable file → Normal file
@ -1,37 +1,37 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
from Fbrowser import SampleMusicBrowser
|
from Fbrowser import SampleMusicBrowser
|
||||||
|
|
||||||
class TestSampleMusicBrowser(unittest.TestCase):
|
class TestSampleMusicBrowser(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.app = QApplication([])
|
self.app = QApplication([])
|
||||||
self.browser = SampleMusicBrowser()
|
self.browser = SampleMusicBrowser()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.app.quit()
|
self.app.quit()
|
||||||
|
|
||||||
def test_player_error(self):
|
def test_player_error(self):
|
||||||
# Mock QMediaPlayer and set error code
|
# Mock QMediaPlayer and set error code
|
||||||
self.browser.player.error = MagicMock(return_value=1)
|
self.browser.player.error = MagicMock(return_value=1)
|
||||||
self.browser.player.errorString = MagicMock(return_value="Test Error")
|
self.browser.player.errorString = MagicMock(return_value="Test Error")
|
||||||
self.browser.player_error(1)
|
self.browser.player_error(1)
|
||||||
# Assert that the error message is printed
|
# Assert that the error message is printed
|
||||||
self.assertIn("An error occurred: Code:1 Test Error", self.browser.console_output)
|
self.assertIn("An error occurred: Code:1 Test Error", self.browser.console_output)
|
||||||
|
|
||||||
def test_player_media_status_changed(self):
|
def test_player_media_status_changed(self):
|
||||||
# Mock QMediaPlayer and set media status
|
# Mock QMediaPlayer and set media status
|
||||||
self.browser.player_media_status_changed(2)
|
self.browser.player_media_status_changed(2)
|
||||||
# Assert that the media status is printed
|
# Assert that the media status is printed
|
||||||
self.assertIn("Media Status: 2", self.browser.console_output)
|
self.assertIn("Media Status: 2", self.browser.console_output)
|
||||||
|
|
||||||
def test_play_file(self):
|
def test_play_file(self):
|
||||||
# Mock QFileSystemModel and set file path
|
# Mock QFileSystemModel and set file path
|
||||||
self.browser.list_model.filePath = MagicMock(return_value="/path/to/file.mp3")
|
self.browser.list_model.filePath = MagicMock(return_value="/path/to/file.mp3")
|
||||||
# Call play_file method
|
# Call play_file method
|
||||||
self.browser.play_file(None)
|
self.browser.play_file(None)
|
||||||
# Assert that the player is playing the correct media
|
# Assert that the player is playing the correct media
|
||||||
self.assertEqual(self.browser.playlist.media(0).canonicalUrl().toString(), "file:///path/to/file.mp3")
|
self.assertEqual(self.browser.playlist.media(0).canonicalUrl().toString(), "file:///path/to/file.mp3")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
22
src-tauri/Cargo.toml
Normal file
22
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "fbrowser-desktop"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.2.0", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
fbrowser-archive = { path = "../crates/fbrowser-archive" }
|
||||||
|
fbrowser-audio = { path = "../crates/fbrowser-audio" }
|
||||||
|
fbrowser-core = { path = "../crates/fbrowser-core" }
|
||||||
|
fbrowser-midi = { path = "../crates/fbrowser-midi" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
tauri.workspace = true
|
||||||
|
tauri-plugin-dialog = "2.3.1"
|
||||||
|
tokio.workspace = true
|
||||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default desktop capability for Fbrowser.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:allow-open",
|
||||||
|
"dialog:allow-save"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default desktop capability for Fbrowser.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save"]}}
|
||||||
2310
src-tauri/gen/schemas/desktop-schema.json
Normal file
2310
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2310
src-tauri/gen/schemas/windows-schema.json
Normal file
2310
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
496
src-tauri/src/main.rs
Normal file
496
src-tauri/src/main.rs
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use fbrowser_archive::{compress, extract, ArchiveJobResult, ArchiveJobSpec};
|
||||||
|
use fbrowser_audio::{generate_waveform, AudioEngine, LoopRegion, PlaybackState};
|
||||||
|
use fbrowser_core::models::{
|
||||||
|
AnnotationUpdate, CollectionItemsMutation, CollectionMutation, CollectionRecord, MediaItemDetail,
|
||||||
|
MetadataPatch, ScanStatus, SearchRequest, SearchResponse,
|
||||||
|
};
|
||||||
|
use fbrowser_core::scanner::{scan_root, ScanProgress};
|
||||||
|
use fbrowser_core::AppDatabase;
|
||||||
|
use fbrowser_midi::{available_backends, MidiBackendConfig, MidiBackendInfo, MidiEngine};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DesktopState {
|
||||||
|
db: AppDatabase,
|
||||||
|
audio: AudioEngine,
|
||||||
|
midi: MidiEngine,
|
||||||
|
scan_status: Arc<Mutex<ScanStatus>>,
|
||||||
|
timer: TimerManager,
|
||||||
|
midi_config: Arc<Mutex<MidiBackendConfig>>,
|
||||||
|
active_media_kind: Arc<Mutex<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
struct TimerState {
|
||||||
|
running: bool,
|
||||||
|
remaining_ms: u64,
|
||||||
|
started_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct TimerManager {
|
||||||
|
state: Arc<Mutex<TimerState>>,
|
||||||
|
task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerManager {
|
||||||
|
fn state(&self) -> TimerState {
|
||||||
|
self.state.lock().expect("timer state poisoned").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&self, app: AppHandle, duration_ms: u64) -> TimerState {
|
||||||
|
self.stop();
|
||||||
|
{
|
||||||
|
let mut state = self.state.lock().expect("timer state poisoned");
|
||||||
|
state.running = true;
|
||||||
|
state.remaining_ms = duration_ms;
|
||||||
|
state.started_at = Some(Utc::now().to_rfc3339());
|
||||||
|
}
|
||||||
|
let state = self.state.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
let snapshot = {
|
||||||
|
let mut state = state.lock().expect("timer state poisoned");
|
||||||
|
if !state.running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if state.remaining_ms <= 1000 {
|
||||||
|
state.remaining_ms = 0;
|
||||||
|
state.running = false;
|
||||||
|
} else {
|
||||||
|
state.remaining_ms -= 1000;
|
||||||
|
}
|
||||||
|
state.clone()
|
||||||
|
};
|
||||||
|
let _ = app.emit("timer:tick", &snapshot);
|
||||||
|
if !snapshot.running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*self.task.lock().expect("timer task poisoned") = Some(task);
|
||||||
|
self.state()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) -> TimerState {
|
||||||
|
if let Some(task) = self.task.lock().expect("timer task poisoned").take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
let mut state = self.state.lock().expect("timer state poisoned");
|
||||||
|
state.running = false;
|
||||||
|
state.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn database_url(app: &AppHandle) -> anyhow::Result<String> {
|
||||||
|
let dir = app.path().app_data_dir()?;
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
let db_path = dir.join("fbrowser.sqlite");
|
||||||
|
Ok(format!("sqlite://{}", db_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn desktop_state<'a>(app_state: &'a State<'_, DesktopState>) -> &'a DesktopState {
|
||||||
|
app_state.inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn scan_add_root(app: AppHandle, state: State<'_, DesktopState>, path: String) -> Result<fbrowser_core::models::LibraryRoot, String> {
|
||||||
|
let root = desktop_state(&state).db.add_root(&path).await.map_err(|err| err.to_string())?;
|
||||||
|
start_scan_job(app, desktop_state(&state).clone(), vec![root.clone()]);
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn scan_remove_root(state: State<'_, DesktopState>, root_id: i64) -> Result<(), String> {
|
||||||
|
desktop_state(&state).db.remove_root(root_id).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn scan_rescan(app: AppHandle, state: State<'_, DesktopState>, root_id: Option<i64>, all: Option<bool>) -> Result<(), String> {
|
||||||
|
let roots = desktop_state(&state).db.list_roots().await.map_err(|err| err.to_string())?;
|
||||||
|
let targets = if all.unwrap_or(false) || root_id.is_none() {
|
||||||
|
roots
|
||||||
|
} else {
|
||||||
|
roots.into_iter().filter(|root| Some(root.id) == root_id).collect()
|
||||||
|
};
|
||||||
|
start_scan_job(app, desktop_state(&state).clone(), targets);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn scan_get_status(state: State<'_, DesktopState>) -> Result<ScanStatus, String> {
|
||||||
|
let mut status = desktop_state(&state).scan_status.lock().expect("scan status poisoned").clone();
|
||||||
|
status.roots = desktop_state(&state).db.list_roots().await.map_err(|err| err.to_string())?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn library_search(state: State<'_, DesktopState>, request: SearchRequest) -> Result<SearchResponse, String> {
|
||||||
|
desktop_state(&state).db.search_library(request).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn library_get_item(state: State<'_, DesktopState>, item_id: i64) -> Result<MediaItemDetail, String> {
|
||||||
|
desktop_state(&state).db.get_item(item_id).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn library_update_annotations(state: State<'_, DesktopState>, payload: AnnotationUpdate) -> Result<MediaItemDetail, String> {
|
||||||
|
desktop_state(&state).db.update_annotations(payload).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn library_write_metadata(state: State<'_, DesktopState>, item_id: i64, patch: MetadataPatch) -> Result<MediaItemDetail, String> {
|
||||||
|
desktop_state(&state).db.write_metadata(item_id, patch).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn collection_list(state: State<'_, DesktopState>) -> Result<Vec<CollectionRecord>, String> {
|
||||||
|
desktop_state(&state).db.list_collections().await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn collection_create(state: State<'_, DesktopState>, payload: CollectionMutation) -> Result<CollectionRecord, String> {
|
||||||
|
desktop_state(&state).db.create_collection(payload).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn collection_update(state: State<'_, DesktopState>, id: i64, payload: CollectionMutation) -> Result<CollectionRecord, String> {
|
||||||
|
desktop_state(&state).db.update_collection(id, payload).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn collection_delete(state: State<'_, DesktopState>, id: i64) -> Result<(), String> {
|
||||||
|
desktop_state(&state).db.delete_collection(id).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn collection_add_items(state: State<'_, DesktopState>, payload: CollectionItemsMutation) -> Result<(), String> {
|
||||||
|
desktop_state(&state).db.add_items_to_collection(payload).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn collection_remove_items(state: State<'_, DesktopState>, payload: CollectionItemsMutation) -> Result<(), String> {
|
||||||
|
desktop_state(&state).db.remove_items_from_collection(payload).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn collection_reorder(state: State<'_, DesktopState>, payload: fbrowser_core::models::ReorderCollectionMutation) -> Result<(), String> {
|
||||||
|
desktop_state(&state).db.reorder_collection(payload).await.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_load(state: State<'_, DesktopState>, path: String, media_kind: String) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
let playback = if media_kind == "midi" {
|
||||||
|
let config = app_state.midi_config.lock().expect("midi config poisoned").clone();
|
||||||
|
app_state.audio.stop();
|
||||||
|
app_state.midi.load(&path, &config).map_err(|err| err.to_string())?
|
||||||
|
} else {
|
||||||
|
app_state.midi.stop();
|
||||||
|
app_state.audio.load(&path, &media_kind).map_err(|err| err.to_string())?
|
||||||
|
};
|
||||||
|
*app_state.active_media_kind.lock().expect("active media kind poisoned") = media_kind;
|
||||||
|
Ok(playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_play(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
let current = if current_media_kind(app_state) == "midi" {
|
||||||
|
let config = app_state.midi_config.lock().expect("midi config poisoned").clone();
|
||||||
|
app_state.midi.play(&config).map_err(|err| err.to_string())?
|
||||||
|
} else {
|
||||||
|
app_state.audio.play()
|
||||||
|
};
|
||||||
|
if let Some(path) = ¤t.loaded_path {
|
||||||
|
if let Some(item_id) = app_state.db.find_item_id_by_path(path).await.map_err(|err| err.to_string())? {
|
||||||
|
let _ = app_state.db.record_play_history(item_id, "transport").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_pause(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
Ok(if current_media_kind(app_state) == "midi" {
|
||||||
|
app_state.midi.pause()
|
||||||
|
} else {
|
||||||
|
app_state.audio.pause()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_stop(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
Ok(if current_media_kind(app_state) == "midi" {
|
||||||
|
app_state.midi.stop()
|
||||||
|
} else {
|
||||||
|
app_state.audio.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_seek(state: State<'_, DesktopState>, position_ms: u64) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
if current_media_kind(app_state) == "midi" {
|
||||||
|
let config = app_state.midi_config.lock().expect("midi config poisoned").clone();
|
||||||
|
app_state.midi.seek(position_ms, &config).map_err(|err| err.to_string())
|
||||||
|
} else {
|
||||||
|
app_state.audio.seek(position_ms).map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_set_volume(state: State<'_, DesktopState>, volume: f32) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
Ok(if current_media_kind(app_state) == "midi" {
|
||||||
|
app_state.midi.set_volume(volume)
|
||||||
|
} else {
|
||||||
|
app_state.audio.set_volume(volume)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_set_loop_region(state: State<'_, DesktopState>, loop_start_ms: Option<u64>, loop_end_ms: Option<u64>) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
let region = match (loop_start_ms, loop_end_ms) {
|
||||||
|
(Some(start_ms), Some(end_ms)) => Some(LoopRegion { start_ms, end_ms }),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Ok(if current_media_kind(app_state) == "midi" {
|
||||||
|
app_state.midi.set_loop_region(region)
|
||||||
|
} else {
|
||||||
|
app_state.audio.set_loop_region(region)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn playback_get_state(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
|
||||||
|
let app_state = desktop_state(&state);
|
||||||
|
Ok(if current_media_kind(app_state) == "midi" {
|
||||||
|
app_state.midi.state()
|
||||||
|
} else {
|
||||||
|
app_state.audio.state()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn waveform_get(state: State<'_, DesktopState>, item_id: i64) -> Result<Vec<f32>, String> {
|
||||||
|
let path = desktop_state(&state).db.get_path_for_item(item_id).await.map_err(|err| err.to_string())?;
|
||||||
|
generate_waveform(&path, 128).map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn midi_get_backends() -> Result<Vec<MidiBackendInfo>, String> {
|
||||||
|
Ok(available_backends())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn midi_get_config(state: State<'_, DesktopState>) -> Result<MidiBackendConfig, String> {
|
||||||
|
Ok(desktop_state(&state).midi_config.lock().expect("midi config poisoned").clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn midi_set_backend(state: State<'_, DesktopState>, config: MidiBackendConfig) -> Result<MidiBackendConfig, String> {
|
||||||
|
desktop_state(&state)
|
||||||
|
.db
|
||||||
|
.set_setting_json("midi_config", &config)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
*desktop_state(&state).midi_config.lock().expect("midi config poisoned") = config.clone();
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn archive_extract(app: AppHandle, spec: ArchiveJobSpec) -> Result<ArchiveJobResult, String> {
|
||||||
|
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "extracting", "source": spec.source }));
|
||||||
|
let result = extract(&spec).map_err(|err| err.to_string())?;
|
||||||
|
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "done", "output": result.output_path }));
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn archive_compress(app: AppHandle, spec: ArchiveJobSpec) -> Result<ArchiveJobResult, String> {
|
||||||
|
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "compressing", "source": spec.source }));
|
||||||
|
let result = compress(&spec).map_err(|err| err.to_string())?;
|
||||||
|
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "done", "output": result.output_path }));
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn timer_start(app: AppHandle, state: State<'_, DesktopState>, duration_ms: u64) -> Result<TimerState, String> {
|
||||||
|
Ok(desktop_state(&state).timer.start(app, duration_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn timer_stop(state: State<'_, DesktopState>) -> Result<TimerState, String> {
|
||||||
|
Ok(desktop_state(&state).timer.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn timer_get_state(state: State<'_, DesktopState>) -> Result<TimerState, String> {
|
||||||
|
Ok(desktop_state(&state).timer.state())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_scan_job(app: AppHandle, state: DesktopState, roots: Vec<fbrowser_core::models::LibraryRoot>) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
{
|
||||||
|
let mut status = state.scan_status.lock().expect("scan status poisoned");
|
||||||
|
status.active = true;
|
||||||
|
status.indexed = 0;
|
||||||
|
status.discovered = 0;
|
||||||
|
status.last_error = None;
|
||||||
|
}
|
||||||
|
for root in roots {
|
||||||
|
let root_path = root.path.clone();
|
||||||
|
{
|
||||||
|
let mut status = state.scan_status.lock().expect("scan status poisoned");
|
||||||
|
status.current_root = Some(root_path.clone());
|
||||||
|
}
|
||||||
|
let app_for_progress = app.clone();
|
||||||
|
let state_for_progress = state.clone();
|
||||||
|
let progress_root_path = root_path.clone();
|
||||||
|
let result = scan_root(&state.db, &root, move |progress: ScanProgress| {
|
||||||
|
let mut status = state_for_progress.scan_status.lock().expect("scan status poisoned");
|
||||||
|
status.discovered = progress.discovered;
|
||||||
|
status.indexed = progress.indexed;
|
||||||
|
let _ = app_for_progress.emit("scan:progress", serde_json::json!({
|
||||||
|
"root": progress_root_path,
|
||||||
|
"discovered": progress.discovered,
|
||||||
|
"indexed": progress.indexed,
|
||||||
|
"currentPath": progress.current_path
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(count) => {
|
||||||
|
let _ = app.emit("scan:item-indexed", serde_json::json!({ "root": root_path, "count": count }));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let mut status = state.scan_status.lock().expect("scan status poisoned");
|
||||||
|
status.last_error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let roots = state.db.list_roots().await.unwrap_or_default();
|
||||||
|
let final_status = {
|
||||||
|
let mut status = state.scan_status.lock().expect("scan status poisoned");
|
||||||
|
status.active = false;
|
||||||
|
status.current_root = None;
|
||||||
|
status.roots = roots;
|
||||||
|
status.clone()
|
||||||
|
};
|
||||||
|
let _ = app.emit("scan:completed", &final_status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_playback_emitter(app: AppHandle, desktop_state: DesktopState) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
let snapshot = if current_media_kind(&desktop_state) == "midi" {
|
||||||
|
desktop_state.midi.state()
|
||||||
|
} else {
|
||||||
|
desktop_state.audio.state()
|
||||||
|
};
|
||||||
|
let _ = app.emit("playback:state", &snapshot);
|
||||||
|
let _ = app.emit(
|
||||||
|
"playback:position",
|
||||||
|
serde_json::json!({ "positionMs": snapshot.position_ms, "durationMs": snapshot.duration_ms }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_state(app: &AppHandle) -> anyhow::Result<DesktopState> {
|
||||||
|
let db_url = database_url(app)?;
|
||||||
|
let db = tauri::async_runtime::block_on(async {
|
||||||
|
let db = AppDatabase::connect(&db_url).await?;
|
||||||
|
sqlx::migrate!("../migrations").run(db.pool()).await?;
|
||||||
|
Ok::<_, anyhow::Error>(db)
|
||||||
|
})?;
|
||||||
|
let midi_config = tauri::async_runtime::block_on(async {
|
||||||
|
db.get_setting_json::<MidiBackendConfig>("midi_config").await
|
||||||
|
})?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(DesktopState {
|
||||||
|
db,
|
||||||
|
audio: AudioEngine::new()?,
|
||||||
|
midi: MidiEngine::new(),
|
||||||
|
scan_status: Arc::new(Mutex::new(ScanStatus::default())),
|
||||||
|
timer: TimerManager::default(),
|
||||||
|
midi_config: Arc::new(Mutex::new(midi_config)),
|
||||||
|
active_media_kind: Arc::new(Mutex::new("audio".into())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_media_kind(state: &DesktopState) -> String {
|
||||||
|
state
|
||||||
|
.active_media_kind
|
||||||
|
.lock()
|
||||||
|
.expect("active media kind poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let desktop_state = build_state(&app.handle())?;
|
||||||
|
spawn_playback_emitter(app.handle().clone(), desktop_state.clone());
|
||||||
|
app.manage(desktop_state);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
scan_add_root,
|
||||||
|
scan_remove_root,
|
||||||
|
scan_rescan,
|
||||||
|
scan_get_status,
|
||||||
|
library_search,
|
||||||
|
library_get_item,
|
||||||
|
library_update_annotations,
|
||||||
|
library_write_metadata,
|
||||||
|
collection_list,
|
||||||
|
collection_create,
|
||||||
|
collection_update,
|
||||||
|
collection_delete,
|
||||||
|
collection_add_items,
|
||||||
|
collection_remove_items,
|
||||||
|
collection_reorder,
|
||||||
|
playback_load,
|
||||||
|
playback_play,
|
||||||
|
playback_pause,
|
||||||
|
playback_stop,
|
||||||
|
playback_seek,
|
||||||
|
playback_set_volume,
|
||||||
|
playback_set_loop_region,
|
||||||
|
playback_get_state,
|
||||||
|
waveform_get,
|
||||||
|
midi_get_backends,
|
||||||
|
midi_get_config,
|
||||||
|
midi_set_backend,
|
||||||
|
archive_extract,
|
||||||
|
archive_compress,
|
||||||
|
timer_start,
|
||||||
|
timer_stop,
|
||||||
|
timer_get_state
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("failed to run Fbrowser desktop");
|
||||||
|
}
|
||||||
32
src-tauri/tauri.conf.json
Normal file
32
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||||
|
"productName": "Fbrowser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.fbrowser.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Fbrowser",
|
||||||
|
"width": 1600,
|
||||||
|
"height": 1040,
|
||||||
|
"minWidth": 1280,
|
||||||
|
"minHeight": 840,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": []
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/App.tsx
Normal file
298
src/App.tsx
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Sidebar } from "./components/Sidebar";
|
||||||
|
import { LibraryPanel } from "./components/LibraryPanel";
|
||||||
|
import { InspectorPanel } from "./components/InspectorPanel";
|
||||||
|
import { TransportBar } from "./components/TransportBar";
|
||||||
|
import { TimerPanel } from "./components/TimerPanel";
|
||||||
|
import { ArchivePanel } from "./components/ArchivePanel";
|
||||||
|
import { SettingsPanel } from "./components/SettingsPanel";
|
||||||
|
import { api } from "./lib/api";
|
||||||
|
import { useUIStore } from "./store/ui";
|
||||||
|
import type {
|
||||||
|
MediaItemSummary,
|
||||||
|
MidiBackendConfig,
|
||||||
|
PlaybackState,
|
||||||
|
SearchRequest,
|
||||||
|
TimerState,
|
||||||
|
} from "./lib/types";
|
||||||
|
|
||||||
|
const initialPlaybackState: PlaybackState = {
|
||||||
|
loaded_path: null,
|
||||||
|
is_playing: false,
|
||||||
|
volume: 0.8,
|
||||||
|
position_ms: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
loop_region: null,
|
||||||
|
output_device: "Default",
|
||||||
|
media_kind: "audio",
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialTimerState: TimerState = {
|
||||||
|
running: false,
|
||||||
|
remaining_ms: 0,
|
||||||
|
started_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { section, setSection, query, setQuery, selectedItem, setSelectedItem } = useUIStore();
|
||||||
|
const [selectedCollectionId, setSelectedCollectionId] = useState<number | null>(null);
|
||||||
|
const [playback, setPlayback] = useState<PlaybackState>(initialPlaybackState);
|
||||||
|
const [timer, setTimer] = useState<TimerState>(initialTimerState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const unsubscribers: Array<() => void> = [];
|
||||||
|
api.playbackGetState().then((value) => mounted && setPlayback(value));
|
||||||
|
api.timerGetState().then((value) => mounted && setTimer(value));
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
api.on<PlaybackState>("playback:state", (payload) => mounted && setPlayback(payload)),
|
||||||
|
api.on<TimerState>("timer:tick", (payload) => mounted && setTimer(payload)),
|
||||||
|
api.on("scan:completed", () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["library-search"] });
|
||||||
|
}),
|
||||||
|
api.on("scan:progress", () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
|
||||||
|
}),
|
||||||
|
]).then((handlers) => {
|
||||||
|
handlers.forEach((unlisten) => unsubscribers.push(unlisten));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||||
|
};
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const scanStatusQuery = useQuery({
|
||||||
|
queryKey: ["scan-status"],
|
||||||
|
queryFn: api.scanGetStatus,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectionsQuery = useQuery({
|
||||||
|
queryKey: ["collections"],
|
||||||
|
queryFn: api.collectionList,
|
||||||
|
});
|
||||||
|
|
||||||
|
const midiBackendsQuery = useQuery({
|
||||||
|
queryKey: ["midi-backends"],
|
||||||
|
queryFn: api.midiGetBackends,
|
||||||
|
});
|
||||||
|
|
||||||
|
const midiConfigQuery = useQuery({
|
||||||
|
queryKey: ["midi-config"],
|
||||||
|
queryFn: api.midiGetConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchRequest = useMemo<SearchRequest>(
|
||||||
|
() => ({
|
||||||
|
query,
|
||||||
|
section: section === "library" || section === "collections" ? undefined : section,
|
||||||
|
page: 0,
|
||||||
|
page_size: 250,
|
||||||
|
collection_id: section === "collections" ? selectedCollectionId ?? undefined : undefined,
|
||||||
|
}),
|
||||||
|
[query, section, selectedCollectionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const libraryQuery = useQuery({
|
||||||
|
queryKey: ["library-search", searchRequest],
|
||||||
|
queryFn: () => api.librarySearch(searchRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedItemDetailQuery = useQuery({
|
||||||
|
queryKey: ["library-item", selectedItem?.id],
|
||||||
|
enabled: Boolean(selectedItem?.id),
|
||||||
|
queryFn: () => api.libraryGetItem(selectedItem!.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const waveformQuery = useQuery({
|
||||||
|
queryKey: ["waveform", selectedItem?.id],
|
||||||
|
enabled: Boolean(selectedItem?.id && selectedItem.media_kind === "audio"),
|
||||||
|
queryFn: () => api.waveformGet(selectedItem!.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addRootMutation = useMutation({
|
||||||
|
mutationFn: api.scanAddRoot,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["library-search"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeRootMutation = useMutation({
|
||||||
|
mutationFn: api.scanRemoveRoot,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["library-search"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCollectionMutation = useMutation({
|
||||||
|
mutationFn: api.collectionCreate,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["collections"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveAnnotationsMutation = useMutation({
|
||||||
|
mutationFn: api.libraryUpdateAnnotations,
|
||||||
|
onSuccess: (detail) => {
|
||||||
|
queryClient.setQueryData(["library-item", detail.summary.id], detail);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["library-search"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeMetadataMutation = useMutation({
|
||||||
|
mutationFn: ({ itemId, patch }: { itemId: number; patch: Record<string, string | null> }) =>
|
||||||
|
api.libraryWriteMetadata(itemId, patch),
|
||||||
|
onSuccess: (detail) => {
|
||||||
|
queryClient.setQueryData(["library-item", detail.summary.id], detail);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["library-search"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToCollectionMutation = useMutation({
|
||||||
|
mutationFn: api.collectionAddItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
const title =
|
||||||
|
section === "favorites"
|
||||||
|
? "Favorites"
|
||||||
|
: section === "recent"
|
||||||
|
? "Recently previewed"
|
||||||
|
: section === "collections"
|
||||||
|
? "Collections"
|
||||||
|
: "Indexed Library";
|
||||||
|
|
||||||
|
const selectedSummary = selectedItemDetailQuery.data?.summary ?? selectedItem;
|
||||||
|
|
||||||
|
async function loadItem(item: MediaItemSummary) {
|
||||||
|
setSelectedItem(item);
|
||||||
|
const loaded = await api.playbackLoad(item.absolute_path, item.media_kind);
|
||||||
|
setPlayback(loaded);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAndPlay(item: MediaItemSummary) {
|
||||||
|
await loadItem(item);
|
||||||
|
const playing = await api.playbackPlay();
|
||||||
|
setPlayback(playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCollection() {
|
||||||
|
createCollectionMutation.mutate({
|
||||||
|
name: `Collection ${new Date().toLocaleTimeString()}`,
|
||||||
|
kind: "playlist",
|
||||||
|
rules_json: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-4">
|
||||||
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
|
<Sidebar
|
||||||
|
section={section}
|
||||||
|
onSectionChange={setSection}
|
||||||
|
collections={collectionsQuery.data ?? []}
|
||||||
|
scanStatus={scanStatusQuery.data}
|
||||||
|
selectedCollectionId={selectedCollectionId}
|
||||||
|
onSelectCollection={setSelectedCollectionId}
|
||||||
|
onCreateCollection={createCollection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4">
|
||||||
|
{section === "archives" ? (
|
||||||
|
<ArchivePanel
|
||||||
|
onExtract={(source, destination) => api.archiveExtract({ source, destination })}
|
||||||
|
onCompress={(source, destination) => api.archiveCompress({ source, destination })}
|
||||||
|
/>
|
||||||
|
) : section === "timer" ? (
|
||||||
|
<TimerPanel
|
||||||
|
timer={timer}
|
||||||
|
onStart={(durationMs) => api.timerStart(durationMs).then(setTimer)}
|
||||||
|
onStop={() => api.timerStop().then(setTimer)}
|
||||||
|
/>
|
||||||
|
) : section === "settings" ? (
|
||||||
|
<SettingsPanel
|
||||||
|
roots={scanStatusQuery.data?.roots ?? []}
|
||||||
|
midiBackends={midiBackendsQuery.data ?? []}
|
||||||
|
midiConfig={midiConfigQuery.data ?? null}
|
||||||
|
onAddRoot={(path) => addRootMutation.mutate(path)}
|
||||||
|
onRemoveRoot={(rootId) => removeRootMutation.mutate(rootId)}
|
||||||
|
onRescanAll={() => api.scanRescan(undefined, true)}
|
||||||
|
onMidiConfigChange={(config: MidiBackendConfig) =>
|
||||||
|
api.midiSetBackend(config).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["midi-config"] });
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LibraryPanel
|
||||||
|
title={title}
|
||||||
|
section={section}
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
items={libraryQuery.data?.items ?? []}
|
||||||
|
total={libraryQuery.data?.total ?? 0}
|
||||||
|
selectedItemId={selectedSummary?.id ?? null}
|
||||||
|
onSelectItem={(item) => void loadItem(item)}
|
||||||
|
onActivateItem={loadAndPlay}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TransportBar
|
||||||
|
playback={playback}
|
||||||
|
onPlay={() =>
|
||||||
|
selectedSummary
|
||||||
|
? playback.loaded_path?.startsWith(selectedSummary.absolute_path)
|
||||||
|
? api.playbackPlay().then(setPlayback)
|
||||||
|
: loadAndPlay(selectedSummary)
|
||||||
|
: api.playbackPlay().then(setPlayback)
|
||||||
|
}
|
||||||
|
onPause={() => api.playbackPause().then(setPlayback)}
|
||||||
|
onStop={() => api.playbackStop().then(setPlayback)}
|
||||||
|
onSeek={(value) => api.playbackSeek(value).then(setPlayback)}
|
||||||
|
onVolume={(value) => api.playbackSetVolume(value).then(setPlayback)}
|
||||||
|
onToggleLoop={() => {
|
||||||
|
const nextRegion = playback.loop_region
|
||||||
|
? [undefined, undefined]
|
||||||
|
: [0, Math.max(1000, playback.duration_ms)];
|
||||||
|
return api.playbackSetLoopRegion(nextRegion[0], nextRegion[1]).then(setPlayback);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InspectorPanel
|
||||||
|
detail={selectedItemDetailQuery.data}
|
||||||
|
waveform={waveformQuery.data ?? []}
|
||||||
|
collections={collectionsQuery.data ?? []}
|
||||||
|
onSaveAnnotations={(payload) => {
|
||||||
|
if (!selectedSummary) return;
|
||||||
|
saveAnnotationsMutation.mutate({
|
||||||
|
item_id: selectedSummary.id,
|
||||||
|
favorite: payload.favorite,
|
||||||
|
rating: payload.rating,
|
||||||
|
note: payload.note,
|
||||||
|
custom_tags: payload.customTags,
|
||||||
|
color: payload.color,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onWriteMetadata={(patch) => {
|
||||||
|
if (!selectedSummary) return;
|
||||||
|
writeMetadataMutation.mutate({ itemId: selectedSummary.id, patch });
|
||||||
|
}}
|
||||||
|
onAddToCollection={(collectionId) => {
|
||||||
|
if (!selectedSummary) return;
|
||||||
|
addToCollectionMutation.mutate({
|
||||||
|
collection_id: collectionId,
|
||||||
|
item_ids: [selectedSummary.id],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/components/ArchivePanel.tsx
Normal file
156
src/components/ArchivePanel.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import type { ArchiveJobResult } from "../lib/types";
|
||||||
|
|
||||||
|
interface ArchivePanelProps {
|
||||||
|
onExtract: (source: string, destination: string) => Promise<ArchiveJobResult>;
|
||||||
|
onCompress: (source: string, destination: string) => Promise<ArchiveJobResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveAction = "extract" | "compress" | null;
|
||||||
|
|
||||||
|
export function ArchivePanel({ onExtract, onCompress }: ArchivePanelProps) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [destination, setDestination] = useState("");
|
||||||
|
const [lastResult, setLastResult] = useState<ArchiveJobResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingAction, setPendingAction] = useState<ArchiveAction>(null);
|
||||||
|
|
||||||
|
async function runAction(action: Exclude<ArchiveAction, null>) {
|
||||||
|
if (!source || !destination) {
|
||||||
|
setError("Choose both a source and destination before running an archive job.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAction(action);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = action === "extract" ? await onExtract(source, destination) : await onCompress(source, destination);
|
||||||
|
setLastResult(result);
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "Archive job failed.");
|
||||||
|
} finally {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-[32px] p-6">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Archive utilities</div>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Extract and package sample crates</h2>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm text-textMuted">
|
||||||
|
Supports <span className="text-text">ZIP</span>, <span className="text-text">TAR</span>, and{" "}
|
||||||
|
<span className="text-text">TAR.GZ / TGZ</span>. Extraction expects an archive file and a target
|
||||||
|
folder. Compression accepts either a file or directory and writes a new archive.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4">
|
||||||
|
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Source</div>
|
||||||
|
<div className="mt-3 flex gap-3">
|
||||||
|
<input
|
||||||
|
value={source}
|
||||||
|
onChange={(event) => setSource(event.target.value)}
|
||||||
|
className="flex-1 rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none"
|
||||||
|
placeholder="Archive file to extract, or file/folder to compress"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
|
||||||
|
onClick={async () => {
|
||||||
|
const picked = await open({
|
||||||
|
directory: false,
|
||||||
|
multiple: false,
|
||||||
|
filters: [{ name: "Archives", extensions: ["zip", "tar", "gz", "tgz"] }],
|
||||||
|
});
|
||||||
|
if (typeof picked === "string") setSource(picked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
File
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
|
||||||
|
onClick={async () => {
|
||||||
|
const picked = await open({ directory: true, multiple: false });
|
||||||
|
if (typeof picked === "string") setSource(picked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Destination</div>
|
||||||
|
<div className="mt-3 flex gap-3">
|
||||||
|
<input
|
||||||
|
value={destination}
|
||||||
|
onChange={(event) => setDestination(event.target.value)}
|
||||||
|
className="flex-1 rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none"
|
||||||
|
placeholder="Target folder for extract, or archive path like crate.tar.gz"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
|
||||||
|
onClick={async () => {
|
||||||
|
const picked = await open({ directory: true, multiple: false });
|
||||||
|
if (typeof picked === "string") setDestination(picked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
|
||||||
|
onClick={async () => {
|
||||||
|
const picked = await save({
|
||||||
|
filters: [
|
||||||
|
{ name: "ZIP", extensions: ["zip"] },
|
||||||
|
{ name: "TAR", extensions: ["tar"] },
|
||||||
|
{ name: "TAR.GZ", extensions: ["tar.gz", "tgz"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (typeof picked === "string") setDestination(picked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save as
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void runAction("extract")}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
className="rounded-2xl bg-accent px-5 py-3 font-semibold text-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pendingAction === "extract" ? "Extracting..." : "Extract"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void runAction("compress")}
|
||||||
|
disabled={pendingAction !== null}
|
||||||
|
className="rounded-2xl border border-line/45 px-5 py-3 text-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pendingAction === "compress" ? "Compressing..." : "Create archive"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-5 rounded-2xl border border-red-400/35 bg-red-500/10 p-4 text-sm text-red-100">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lastResult ? (
|
||||||
|
<div className="mt-5 rounded-2xl border border-line/35 bg-white/5 p-4 text-sm text-text">
|
||||||
|
Output: {lastResult.output_path}
|
||||||
|
<div className="mt-2 text-textMuted">{lastResult.processed_entries} items processed</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/components/InspectorPanel.tsx
Normal file
229
src/components/InspectorPanel.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Disc3, FileAudio, Music3, Star } from "lucide-react";
|
||||||
|
import type { CollectionRecord, MediaItemDetail } from "../lib/types";
|
||||||
|
import { formatDuration, formatFileSize } from "../lib/format";
|
||||||
|
|
||||||
|
interface InspectorPanelProps {
|
||||||
|
detail: MediaItemDetail | null | undefined;
|
||||||
|
waveform: number[];
|
||||||
|
collections: CollectionRecord[];
|
||||||
|
onSaveAnnotations: (payload: {
|
||||||
|
favorite: boolean;
|
||||||
|
rating: number | null;
|
||||||
|
note: string;
|
||||||
|
customTags: string[];
|
||||||
|
color: string;
|
||||||
|
}) => void;
|
||||||
|
onWriteMetadata: (patch: Record<string, string | null>) => void;
|
||||||
|
onAddToCollection: (collectionId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectorPanel({
|
||||||
|
detail,
|
||||||
|
waveform,
|
||||||
|
collections,
|
||||||
|
onSaveAnnotations,
|
||||||
|
onWriteMetadata,
|
||||||
|
onAddToCollection,
|
||||||
|
}: InspectorPanelProps) {
|
||||||
|
const summary = detail?.summary;
|
||||||
|
const [favorite, setFavorite] = useState(false);
|
||||||
|
const [rating, setRating] = useState<number | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [tagsText, setTagsText] = useState("");
|
||||||
|
const [color, setColor] = useState("#24c4ff");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [artist, setArtist] = useState("");
|
||||||
|
const [album, setAlbum] = useState("");
|
||||||
|
const [genre, setGenre] = useState("");
|
||||||
|
const [year, setYear] = useState("");
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFavorite(summary?.favorite ?? false);
|
||||||
|
setRating(summary?.rating ?? null);
|
||||||
|
setNote(summary?.note ?? "");
|
||||||
|
setTagsText(detail?.custom_tags.join(", ") ?? "");
|
||||||
|
setColor(summary?.color ?? "#24c4ff");
|
||||||
|
setTitle(summary?.title ?? "");
|
||||||
|
setArtist(summary?.artist ?? "");
|
||||||
|
setAlbum(summary?.album ?? "");
|
||||||
|
setGenre(summary?.genre ?? "");
|
||||||
|
setYear(summary?.year ?? "");
|
||||||
|
setComment(summary?.comment ?? "");
|
||||||
|
}, [detail, summary]);
|
||||||
|
|
||||||
|
const statRows = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Type", value: summary?.media_kind ?? "--" },
|
||||||
|
{ label: "Length", value: formatDuration(summary?.duration_ms) },
|
||||||
|
{ label: "Size", value: formatFileSize(summary?.size_bytes) },
|
||||||
|
{ label: "Rate", value: summary?.sample_rate ? `${summary.sample_rate} Hz` : "--" },
|
||||||
|
],
|
||||||
|
[summary],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="glass flex h-full w-[360px] flex-col gap-4 rounded-[32px] p-5">
|
||||||
|
<div className="rounded-[28px] border border-line/40 bg-black/15 p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accentSoft/80 text-accent">
|
||||||
|
{summary?.media_kind === "midi" ? <Music3 className="h-5 w-5" /> : <FileAudio className="h-5 w-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-lg font-semibold text-white">
|
||||||
|
{summary ? summary.title || summary.file_name : "No selection"}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm text-textMuted">
|
||||||
|
{summary ? summary.absolute_path : "Select a media item to inspect metadata and notes."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||||
|
{statRows.map((row) => (
|
||||||
|
<div key={row.label} className="rounded-2xl border border-line/35 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.22em] text-textMuted">{row.label}</div>
|
||||||
|
<div className="mt-2 text-sm text-white">{row.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 h-24 rounded-2xl border border-line/35 bg-panel/60 p-3">
|
||||||
|
<div className="flex h-full items-end gap-[3px]">
|
||||||
|
{waveform.length === 0
|
||||||
|
? Array.from({ length: 48 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 rounded-full bg-white/10"
|
||||||
|
style={{ height: `${12 + (index % 8) * 6}%` }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: waveform.map((value, index) => (
|
||||||
|
<div
|
||||||
|
key={`${value}-${index}`}
|
||||||
|
className="flex-1 rounded-full bg-accent/80"
|
||||||
|
style={{ height: `${Math.max(8, value * 100)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scrollbar-thin flex-1 space-y-4 overflow-auto pr-1">
|
||||||
|
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium text-white">Catalog annotations</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onSaveAnnotations({
|
||||||
|
favorite,
|
||||||
|
rating,
|
||||||
|
note,
|
||||||
|
customTags: tagsText
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!summary}
|
||||||
|
className="rounded-xl bg-accent px-3 py-2 text-xs font-medium text-black disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Save local annotations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 flex items-center gap-3 rounded-2xl border border-line/35 px-3 py-3 text-sm text-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={favorite}
|
||||||
|
onChange={(event) => setFavorite(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Favorite this item
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-textMuted">Rating</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRating(value)}
|
||||||
|
className={value <= (rating ?? 0) ? "text-accent" : "text-textMuted"}
|
||||||
|
>
|
||||||
|
<Star className="h-5 w-5 fill-current" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Session notes, pack notes, vibe, mix reminders"
|
||||||
|
className="w-full rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={tagsText}
|
||||||
|
onChange={(event) => setTagsText(event.target.value)}
|
||||||
|
placeholder="custom tags, comma separated"
|
||||||
|
className="w-full rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center justify-between rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text">
|
||||||
|
Accent
|
||||||
|
<input value={color} onChange={(event) => setColor(event.target.value)} type="color" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-white">
|
||||||
|
<Disc3 className="h-4 w-4 text-accent" />
|
||||||
|
Write metadata to file
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Title" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
|
||||||
|
<input value={artist} onChange={(event) => setArtist(event.target.value)} placeholder="Artist" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
|
||||||
|
<input value={album} onChange={(event) => setAlbum(event.target.value)} placeholder="Album" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input value={genre} onChange={(event) => setGenre(event.target.value)} placeholder="Genre" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
|
||||||
|
<input value={year} onChange={(event) => setYear(event.target.value)} placeholder="Year" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
|
||||||
|
</div>
|
||||||
|
<textarea value={comment} onChange={(event) => setComment(event.target.value)} placeholder="Comment" rows={3} className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onWriteMetadata({ title, artist, album, genre, year, comment })}
|
||||||
|
disabled={!summary}
|
||||||
|
className="rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Write metadata to source file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
|
||||||
|
<div className="mb-3 text-sm font-medium text-white">Collections</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<button
|
||||||
|
key={collection.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddToCollection(collection.id)}
|
||||||
|
disabled={!summary}
|
||||||
|
className="flex w-full items-center justify-between rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-left text-sm text-text transition hover:border-line/60 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<span>{collection.name}</span>
|
||||||
|
<span className="text-xs uppercase tracking-[0.18em] text-textMuted">{collection.kind}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/LibraryPanel.tsx
Normal file
124
src/components/LibraryPanel.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Search, Waves } from "lucide-react";
|
||||||
|
import type { MediaItemSummary, SectionKey } from "../lib/types";
|
||||||
|
import { formatDuration, formatFileSize } from "../lib/format";
|
||||||
|
|
||||||
|
interface LibraryPanelProps {
|
||||||
|
title: string;
|
||||||
|
section: SectionKey;
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
items: MediaItemSummary[];
|
||||||
|
total: number;
|
||||||
|
selectedItemId: number | null;
|
||||||
|
onSelectItem: (item: MediaItemSummary) => void;
|
||||||
|
onActivateItem: (item: MediaItemSummary) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryPanel({
|
||||||
|
title,
|
||||||
|
section,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
selectedItemId,
|
||||||
|
onSelectItem,
|
||||||
|
onActivateItem,
|
||||||
|
}: LibraryPanelProps) {
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 78,
|
||||||
|
overscan: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = useMemo(() => {
|
||||||
|
switch (section) {
|
||||||
|
case "favorites":
|
||||||
|
return "Pinned sounds, cues, and go-to preview targets.";
|
||||||
|
case "recent":
|
||||||
|
return "Recent playback history across the indexed library.";
|
||||||
|
case "collections":
|
||||||
|
return "Curated crates and smart bins for retrieval speed.";
|
||||||
|
default:
|
||||||
|
return "High-speed indexed browsing with low-latency transport controls.";
|
||||||
|
}
|
||||||
|
}, [section]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glass flex h-full min-w-0 flex-1 flex-col rounded-[32px] p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.24em] text-textMuted">{section}</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-white">{title}</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-textMuted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[280px] max-w-[360px] flex-1">
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/45 bg-white/5 px-4 py-3 text-sm text-textMuted">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
className="w-full bg-transparent outline-none placeholder:text-textMuted/70"
|
||||||
|
placeholder="Search file name, title, artist, album"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-center justify-between rounded-2xl border border-line/35 bg-black/10 px-4 py-3 text-xs uppercase tracking-[0.2em] text-textMuted">
|
||||||
|
<span>{total.toLocaleString()} indexed items</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Waves className="h-4 w-4" />
|
||||||
|
Preview-ready browser
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={parentRef} className="scrollbar-thin flex-1 overflow-auto pr-1">
|
||||||
|
<div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const item = items[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
onDoubleClick={() => onActivateItem(item)}
|
||||||
|
className={clsx(
|
||||||
|
"absolute left-0 top-0 flex w-full items-center gap-4 rounded-3xl border px-4 py-4 text-left transition",
|
||||||
|
selectedItemId === item.id
|
||||||
|
? "border-accent/50 bg-accentSoft/70 shadow-glow"
|
||||||
|
: "border-transparent bg-white/0 hover:border-line/40 hover:bg-white/5",
|
||||||
|
)}
|
||||||
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||||
|
>
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-panelAlt text-sm font-semibold uppercase text-accent">
|
||||||
|
{item.extension}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-base font-medium text-white">
|
||||||
|
{item.title || item.file_name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-textMuted">
|
||||||
|
<span className="truncate">{item.artist || item.album || item.absolute_path}</span>
|
||||||
|
<span>{formatDuration(item.duration_ms)}</span>
|
||||||
|
<span>{formatFileSize(item.size_bytes)}</span>
|
||||||
|
<span className="uppercase">{item.media_kind}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-textMuted">
|
||||||
|
<div>{item.favorite ? "Favorite" : "Indexed"}</div>
|
||||||
|
<div className="mt-1">{item.rating ? `${item.rating}/5` : "Unrated"}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/SettingsPanel.tsx
Normal file
123
src/components/SettingsPanel.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import type { LibraryRoot, MidiBackendConfig, MidiBackendInfo } from "../lib/types";
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
roots: LibraryRoot[];
|
||||||
|
midiBackends: MidiBackendInfo[];
|
||||||
|
midiConfig: MidiBackendConfig | null;
|
||||||
|
onAddRoot: (path: string) => void;
|
||||||
|
onRemoveRoot: (rootId: number) => void;
|
||||||
|
onRescanAll: () => void;
|
||||||
|
onMidiConfigChange: (config: MidiBackendConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
roots,
|
||||||
|
midiBackends,
|
||||||
|
midiConfig,
|
||||||
|
onAddRoot,
|
||||||
|
onRemoveRoot,
|
||||||
|
onRescanAll,
|
||||||
|
onMidiConfigChange,
|
||||||
|
}: SettingsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-[32px] p-6">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<section className="rounded-3xl border border-line/35 bg-white/5 p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Watched roots</div>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Library paths</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
|
||||||
|
onClick={onRescanAll}
|
||||||
|
>
|
||||||
|
Rescan all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl bg-accent px-4 py-3 font-semibold text-black"
|
||||||
|
onClick={async () => {
|
||||||
|
const picked = await open({ directory: true, multiple: false });
|
||||||
|
if (typeof picked === "string") onAddRoot(picked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add root
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{roots.map((root) => (
|
||||||
|
<div key={root.id} className="flex items-center justify-between rounded-2xl border border-line/35 bg-panel/70 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-white">{root.path}</div>
|
||||||
|
<div className="mt-1 text-xs text-textMuted">{root.item_count} indexed items</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-line/45 px-3 py-2 text-xs text-text"
|
||||||
|
onClick={() => onRemoveRoot(root.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-line/35 bg-white/5 p-5">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">MIDI backend</div>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Playback routing</h2>
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
<select
|
||||||
|
value={midiConfig?.backend_id ?? "system"}
|
||||||
|
onChange={(event) =>
|
||||||
|
onMidiConfigChange({
|
||||||
|
backend_id: event.target.value,
|
||||||
|
soundfont_path: midiConfig?.soundfont_path ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none"
|
||||||
|
>
|
||||||
|
{midiBackends.map((backend) => (
|
||||||
|
<option key={backend.id} value={backend.id}>
|
||||||
|
{backend.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-line/35 bg-panel/70 p-3">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">SoundFont path</div>
|
||||||
|
<div className="mt-2 break-all text-sm text-text">
|
||||||
|
{midiConfig?.soundfont_path || "No soundfont selected"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-2xl border border-line/45 px-4 py-3 text-text"
|
||||||
|
onClick={async () => {
|
||||||
|
const picked = await open({
|
||||||
|
directory: false,
|
||||||
|
filters: [{ name: "SoundFont", extensions: ["sf2", "sf3"] }],
|
||||||
|
});
|
||||||
|
if (typeof picked === "string") {
|
||||||
|
onMidiConfigChange({
|
||||||
|
backend_id: midiConfig?.backend_id ?? "soundfont",
|
||||||
|
soundfont_path: picked,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choose SoundFont
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/Sidebar.tsx
Normal file
141
src/components/Sidebar.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Clock3,
|
||||||
|
FolderArchive,
|
||||||
|
Heart,
|
||||||
|
LibraryBig,
|
||||||
|
Music2,
|
||||||
|
Settings2,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import type { CollectionRecord, ScanStatus, SectionKey } from "../lib/types";
|
||||||
|
|
||||||
|
const sections: { key: SectionKey; label: string; icon: ComponentType<{ className?: string }> }[] = [
|
||||||
|
{ key: "library", label: "Library", icon: LibraryBig },
|
||||||
|
{ key: "favorites", label: "Favorites", icon: Heart },
|
||||||
|
{ key: "recent", label: "Recent", icon: Music2 },
|
||||||
|
{ key: "collections", label: "Collections", icon: Sparkles },
|
||||||
|
{ key: "archives", label: "Archive Tools", icon: FolderArchive },
|
||||||
|
{ key: "timer", label: "Timer", icon: Clock3 },
|
||||||
|
{ key: "settings", label: "Settings", icon: Settings2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
section: SectionKey;
|
||||||
|
onSectionChange: (section: SectionKey) => void;
|
||||||
|
collections: CollectionRecord[];
|
||||||
|
scanStatus?: ScanStatus;
|
||||||
|
selectedCollectionId: number | null;
|
||||||
|
onSelectCollection: (id: number) => void;
|
||||||
|
onCreateCollection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
section,
|
||||||
|
onSectionChange,
|
||||||
|
collections,
|
||||||
|
scanStatus,
|
||||||
|
selectedCollectionId,
|
||||||
|
onSelectCollection,
|
||||||
|
onCreateCollection,
|
||||||
|
}: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="glass flex h-full w-[300px] flex-col rounded-[28px] p-5">
|
||||||
|
<div className="mb-5">
|
||||||
|
<div className="text-xs uppercase tracking-[0.28em] text-textMuted">Indexed sample browser</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold tracking-tight text-white">Fbrowser</div>
|
||||||
|
<div className="mt-2 text-sm text-textMuted">
|
||||||
|
Rust core, indexed library workflows, and producer-focused preview tools.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sections.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSectionChange(key)}
|
||||||
|
className={clsx(
|
||||||
|
"relative flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition",
|
||||||
|
section === key
|
||||||
|
? "border-accent/50 bg-accentSoft/70 text-white shadow-glow"
|
||||||
|
: "border-transparent bg-white/0 text-textMuted hover:border-line/60 hover:bg-white/5 hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section === key ? (
|
||||||
|
<motion.div
|
||||||
|
layoutId="sidebar-highlight"
|
||||||
|
className="absolute inset-0 rounded-2xl border border-accent/30"
|
||||||
|
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Icon className="relative z-10 h-4 w-4" />
|
||||||
|
<span className="relative z-10 font-medium">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex-1 overflow-hidden rounded-3xl border border-line/40 bg-black/10 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium text-white">Collections</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-xs text-textMuted">{collections.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreateCollection}
|
||||||
|
className="rounded-xl border border-line/45 px-3 py-1.5 text-xs text-text transition hover:border-accent/50 hover:text-white"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-thin h-full space-y-2 overflow-auto pr-1">
|
||||||
|
{collections.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-line/50 p-4 text-sm text-textMuted">
|
||||||
|
Create curated sets, favorites, and smart bins from the inspector or library actions.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
collections.map((collection) => (
|
||||||
|
<button
|
||||||
|
key={collection.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSectionChange("collections");
|
||||||
|
onSelectCollection(collection.id);
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"w-full rounded-2xl border px-3 py-3 text-left transition",
|
||||||
|
selectedCollectionId === collection.id && section === "collections"
|
||||||
|
? "border-accent/50 bg-accentSoft/60 text-white"
|
||||||
|
: "border-line/35 bg-white/5 text-text hover:border-line/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{collection.name}</div>
|
||||||
|
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-textMuted">{collection.kind}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-3xl border border-line/35 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm text-white">
|
||||||
|
<span>Indexer</span>
|
||||||
|
<span className={scanStatus?.active ? "text-accent" : "text-success"}>
|
||||||
|
{scanStatus?.active ? "Scanning" : "Ready"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-textMuted">
|
||||||
|
{scanStatus?.active
|
||||||
|
? `${scanStatus.indexed} indexed / ${scanStatus.discovered} discovered`
|
||||||
|
: `${scanStatus?.roots.length ?? 0} watched roots`}
|
||||||
|
</div>
|
||||||
|
{scanStatus?.current_root ? (
|
||||||
|
<div className="mt-2 truncate text-xs text-textMuted">{scanStatus.current_root}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/TimerPanel.tsx
Normal file
53
src/components/TimerPanel.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { TimerReset } from "lucide-react";
|
||||||
|
import type { TimerState } from "../lib/types";
|
||||||
|
import { formatCountdown } from "../lib/format";
|
||||||
|
|
||||||
|
interface TimerPanelProps {
|
||||||
|
timer: TimerState;
|
||||||
|
onStart: (durationMs: number) => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimerPanel({ timer, onStart, onStop }: TimerPanelProps) {
|
||||||
|
const [hours, setHours] = useState("0");
|
||||||
|
const [minutes, setMinutes] = useState("30");
|
||||||
|
const [seconds, setSeconds] = useState("0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-[32px] p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-accentSoft text-accent">
|
||||||
|
<TimerReset className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Focus timer</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-white">{formatCountdown(timer.remaining_ms)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||||
|
<input value={hours} onChange={(event) => setHours(event.target.value)} className="rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none" placeholder="Hours" />
|
||||||
|
<input value={minutes} onChange={(event) => setMinutes(event.target.value)} className="rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none" placeholder="Minutes" />
|
||||||
|
<input value={seconds} onChange={(event) => setSeconds(event.target.value)} className="rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none" placeholder="Seconds" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onStart(
|
||||||
|
(Number(hours || 0) * 3600 + Number(minutes || 0) * 60 + Number(seconds || 0)) * 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-2xl bg-accent px-5 py-3 font-semibold text-black"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onStop} className="rounded-2xl border border-line/45 px-5 py-3 text-text">
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/TransportBar.tsx
Normal file
79
src/components/TransportBar.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Pause, Play, Repeat, Square, Volume2 } from "lucide-react";
|
||||||
|
import type { PlaybackState } from "../lib/types";
|
||||||
|
import { formatDuration } from "../lib/format";
|
||||||
|
|
||||||
|
interface TransportBarProps {
|
||||||
|
playback: PlaybackState;
|
||||||
|
onPlay: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onSeek: (value: number) => void;
|
||||||
|
onVolume: (value: number) => void;
|
||||||
|
onToggleLoop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransportBar({
|
||||||
|
playback,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onStop,
|
||||||
|
onSeek,
|
||||||
|
onVolume,
|
||||||
|
onToggleLoop,
|
||||||
|
}: TransportBarProps) {
|
||||||
|
return (
|
||||||
|
<footer className="glass mt-4 shrink-0 flex items-center gap-4 rounded-[28px] px-5 py-4">
|
||||||
|
<div className="min-w-[240px]">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-textMuted">Transport</div>
|
||||||
|
<div className="mt-1 truncate text-base font-semibold text-white">
|
||||||
|
{playback.loaded_path ?? "No file loaded"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={onPlay} className="rounded-2xl bg-accent px-4 py-3 text-black">
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onPause} className="rounded-2xl border border-line/45 px-4 py-3 text-text">
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onStop} className="rounded-2xl border border-line/45 px-4 py-3 text-text">
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleLoop}
|
||||||
|
className={`rounded-2xl border px-4 py-3 ${playback.loop_region ? "border-accent/60 text-accent" : "border-line/45 text-text"}`}
|
||||||
|
>
|
||||||
|
<Repeat className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={playback.duration_ms || 1}
|
||||||
|
value={Math.min(playback.position_ms, playback.duration_ms || 1)}
|
||||||
|
onChange={(event) => onSeek(Number(event.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-xs text-textMuted">
|
||||||
|
<span>{formatDuration(playback.position_ms)}</span>
|
||||||
|
<span>{formatDuration(playback.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-[220px] items-center gap-3">
|
||||||
|
<Volume2 className="h-4 w-4 text-textMuted" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={playback.volume}
|
||||||
|
onChange={(event) => onVolume(Number(event.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="w-12 text-right text-sm text-textMuted">{Math.round(playback.volume * 100)}%</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/lib/api.ts
Normal file
63
src/lib/api.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import type {
|
||||||
|
AnnotationUpdate,
|
||||||
|
ArchiveJobResult,
|
||||||
|
ArchiveJobSpec,
|
||||||
|
CollectionItemsMutation,
|
||||||
|
CollectionMutation,
|
||||||
|
CollectionRecord,
|
||||||
|
MediaItemDetail,
|
||||||
|
MidiBackendConfig,
|
||||||
|
MidiBackendInfo,
|
||||||
|
PlaybackState,
|
||||||
|
ScanStatus,
|
||||||
|
SearchRequest,
|
||||||
|
SearchResponse,
|
||||||
|
TimerState,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
scanAddRoot: (path: string) => invoke("scan_add_root", { path }),
|
||||||
|
scanRemoveRoot: (rootId: number) => invoke("scan_remove_root", { rootId }),
|
||||||
|
scanRescan: (rootId?: number, all = false) => invoke("scan_rescan", { rootId, all }),
|
||||||
|
scanGetStatus: () => invoke<ScanStatus>("scan_get_status"),
|
||||||
|
librarySearch: (request: SearchRequest) => invoke<SearchResponse>("library_search", { request }),
|
||||||
|
libraryGetItem: (itemId: number) => invoke<MediaItemDetail>("library_get_item", { itemId }),
|
||||||
|
libraryUpdateAnnotations: (payload: AnnotationUpdate) =>
|
||||||
|
invoke<MediaItemDetail>("library_update_annotations", { payload }),
|
||||||
|
libraryWriteMetadata: (itemId: number, patch: Record<string, string | null>) =>
|
||||||
|
invoke<MediaItemDetail>("library_write_metadata", { itemId, patch }),
|
||||||
|
collectionList: () => invoke<CollectionRecord[]>("collection_list"),
|
||||||
|
collectionCreate: (payload: CollectionMutation) =>
|
||||||
|
invoke<CollectionRecord>("collection_create", { payload }),
|
||||||
|
collectionUpdate: (id: number, payload: CollectionMutation) =>
|
||||||
|
invoke<CollectionRecord>("collection_update", { id, payload }),
|
||||||
|
collectionDelete: (id: number) => invoke("collection_delete", { id }),
|
||||||
|
collectionAddItems: (payload: CollectionItemsMutation) =>
|
||||||
|
invoke("collection_add_items", { payload }),
|
||||||
|
collectionRemoveItems: (payload: CollectionItemsMutation) =>
|
||||||
|
invoke("collection_remove_items", { payload }),
|
||||||
|
playbackLoad: (path: string, mediaKind: string) =>
|
||||||
|
invoke<PlaybackState>("playback_load", { path, mediaKind }),
|
||||||
|
playbackPlay: () => invoke<PlaybackState>("playback_play"),
|
||||||
|
playbackPause: () => invoke<PlaybackState>("playback_pause"),
|
||||||
|
playbackStop: () => invoke<PlaybackState>("playback_stop"),
|
||||||
|
playbackSeek: (positionMs: number) => invoke<PlaybackState>("playback_seek", { positionMs }),
|
||||||
|
playbackSetVolume: (volume: number) => invoke<PlaybackState>("playback_set_volume", { volume }),
|
||||||
|
playbackSetLoopRegion: (loopStartMs?: number, loopEndMs?: number) =>
|
||||||
|
invoke<PlaybackState>("playback_set_loop_region", { loopStartMs, loopEndMs }),
|
||||||
|
playbackGetState: () => invoke<PlaybackState>("playback_get_state"),
|
||||||
|
waveformGet: (itemId: number) => invoke<number[]>("waveform_get", { itemId }),
|
||||||
|
midiGetBackends: () => invoke<MidiBackendInfo[]>("midi_get_backends"),
|
||||||
|
midiGetConfig: () => invoke<MidiBackendConfig>("midi_get_config"),
|
||||||
|
midiSetBackend: (config: MidiBackendConfig) => invoke<MidiBackendConfig>("midi_set_backend", { config }),
|
||||||
|
archiveExtract: (spec: ArchiveJobSpec) => invoke<ArchiveJobResult>("archive_extract", { spec }),
|
||||||
|
archiveCompress: (spec: ArchiveJobSpec) => invoke<ArchiveJobResult>("archive_compress", { spec }),
|
||||||
|
timerStart: (durationMs: number) => invoke<TimerState>("timer_start", { durationMs }),
|
||||||
|
timerStop: () => invoke<TimerState>("timer_stop"),
|
||||||
|
timerGetState: () => invoke<TimerState>("timer_get_state"),
|
||||||
|
on<T>(event: string, handler: (payload: T) => void) {
|
||||||
|
return listen<T>(event, ({ payload }) => handler(payload));
|
||||||
|
},
|
||||||
|
};
|
||||||
27
src/lib/format.ts
Normal file
27
src/lib/format.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export function formatDuration(durationMs?: number | null) {
|
||||||
|
if (!durationMs) return "--:--";
|
||||||
|
const totalSeconds = Math.floor(durationMs / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(sizeBytes?: number | null) {
|
||||||
|
if (!sizeBytes) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let size = sizeBytes;
|
||||||
|
let index = 0;
|
||||||
|
while (size >= 1024 && index < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCountdown(durationMs: number) {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return [hours, minutes, seconds].map((value) => value.toString().padStart(2, "0")).join(":");
|
||||||
|
}
|
||||||
157
src/lib/types.ts
Normal file
157
src/lib/types.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
export type SectionKey =
|
||||||
|
| "library"
|
||||||
|
| "favorites"
|
||||||
|
| "recent"
|
||||||
|
| "collections"
|
||||||
|
| "archives"
|
||||||
|
| "timer"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
|
export interface LibraryRoot {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
enabled: boolean;
|
||||||
|
platform: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaItemSummary {
|
||||||
|
id: number;
|
||||||
|
root_id: number;
|
||||||
|
absolute_path: string;
|
||||||
|
file_name: string;
|
||||||
|
extension: string;
|
||||||
|
media_kind: string;
|
||||||
|
size_bytes: number;
|
||||||
|
mtime_unix: number;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
sample_rate?: number | null;
|
||||||
|
channels?: number | null;
|
||||||
|
bpm?: number | null;
|
||||||
|
musical_key?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
artist?: string | null;
|
||||||
|
album?: string | null;
|
||||||
|
genre?: string | null;
|
||||||
|
year?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
embedded_bpm?: number | null;
|
||||||
|
favorite: boolean;
|
||||||
|
rating?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
custom_tags_json?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
last_played_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaItemDetail {
|
||||||
|
summary: MediaItemSummary;
|
||||||
|
custom_tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchRequest {
|
||||||
|
query?: string | null;
|
||||||
|
section?: string | null;
|
||||||
|
sort?: string | null;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
root_id?: number | null;
|
||||||
|
collection_id?: number | null;
|
||||||
|
media_kind?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
items: MediaItemSummary[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationUpdate {
|
||||||
|
item_id: number;
|
||||||
|
favorite?: boolean | null;
|
||||||
|
rating?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
custom_tags?: string[] | null;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataPatch {
|
||||||
|
title?: string | null;
|
||||||
|
artist?: string | null;
|
||||||
|
album?: string | null;
|
||||||
|
genre?: string | null;
|
||||||
|
year?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionRecord {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
rules_json?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionMutation {
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
rules_json?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionItemsMutation {
|
||||||
|
collection_id: number;
|
||||||
|
item_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanStatus {
|
||||||
|
active: boolean;
|
||||||
|
current_root?: string | null;
|
||||||
|
indexed: number;
|
||||||
|
discovered: number;
|
||||||
|
last_error?: string | null;
|
||||||
|
roots: LibraryRoot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackState {
|
||||||
|
loaded_path?: string | null;
|
||||||
|
is_playing: boolean;
|
||||||
|
volume: number;
|
||||||
|
position_ms: number;
|
||||||
|
duration_ms: number;
|
||||||
|
loop_region?: {
|
||||||
|
start_ms: number;
|
||||||
|
end_ms: number;
|
||||||
|
} | null;
|
||||||
|
output_device?: string | null;
|
||||||
|
media_kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MidiBackendInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
supports_soundfont: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MidiBackendConfig {
|
||||||
|
backend_id: string;
|
||||||
|
soundfont_path?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveJobSpec {
|
||||||
|
source: string;
|
||||||
|
destination: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveJobResult {
|
||||||
|
output_path: string;
|
||||||
|
processed_entries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimerState {
|
||||||
|
running: boolean;
|
||||||
|
remaining_ms: number;
|
||||||
|
started_at?: string | null;
|
||||||
|
}
|
||||||
15
src/main.tsx
Normal file
15
src/main.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
20
src/store/ui.ts
Normal file
20
src/store/ui.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { MediaItemSummary, SectionKey } from "../lib/types";
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
section: SectionKey;
|
||||||
|
query: string;
|
||||||
|
selectedItem: MediaItemSummary | null;
|
||||||
|
setSection: (section: SectionKey) => void;
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
setSelectedItem: (item: MediaItemSummary | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
|
section: "library",
|
||||||
|
query: "",
|
||||||
|
selectedItem: null,
|
||||||
|
setSection: (section) => set({ section }),
|
||||||
|
setQuery: (query) => set({ query }),
|
||||||
|
setSelectedItem: (selectedItem) => set({ selectedItem }),
|
||||||
|
}));
|
||||||
67
src/styles/index.css
Normal file
67
src/styles/index.css
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-canvas: 7 10 16;
|
||||||
|
--color-panel: 15 20 29;
|
||||||
|
--color-panel-alt: 23 29 40;
|
||||||
|
--color-line: 61 74 96;
|
||||||
|
--color-accent: 36 196 255;
|
||||||
|
--color-accent-soft: 23 59 79;
|
||||||
|
--color-text: 237 240 248;
|
||||||
|
--color-text-muted: 145 157 181;
|
||||||
|
--color-danger: 255 95 109;
|
||||||
|
--color-success: 47 211 151;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(36, 196, 255, 0.12), transparent 24%),
|
||||||
|
radial-gradient(circle at right, rgba(24, 195, 125, 0.12), transparent 22%),
|
||||||
|
linear-gradient(180deg, rgba(16, 22, 31, 0.98), rgba(4, 6, 10, 1));
|
||||||
|
font-family: "Outfit", "Inter", ui-sans-serif, system-ui;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: linear-gradient(180deg, rgba(24, 31, 44, 0.88), rgba(12, 16, 22, 0.72));
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid rgba(93, 108, 133, 0.28);
|
||||||
|
box-shadow: 0 16px 45px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(88, 104, 130, 0.65) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(88, 104, 130, 0.65);
|
||||||
|
}
|
||||||
32
tailwind.config.js
Normal file
32
tailwind.config.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
canvas: "rgb(var(--color-canvas) / <alpha-value>)",
|
||||||
|
panel: "rgb(var(--color-panel) / <alpha-value>)",
|
||||||
|
panelAlt: "rgb(var(--color-panel-alt) / <alpha-value>)",
|
||||||
|
line: "rgb(var(--color-line) / <alpha-value>)",
|
||||||
|
accent: "rgb(var(--color-accent) / <alpha-value>)",
|
||||||
|
accentSoft: "rgb(var(--color-accent-soft) / <alpha-value>)",
|
||||||
|
text: "rgb(var(--color-text) / <alpha-value>)",
|
||||||
|
textMuted: "rgb(var(--color-text-muted) / <alpha-value>)",
|
||||||
|
danger: "rgb(var(--color-danger) / <alpha-value>)",
|
||||||
|
success: "rgb(var(--color-success) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: "0 18px 70px rgba(36, 196, 255, 0.12)",
|
||||||
|
panel: "0 16px 45px rgba(0, 0, 0, 0.28)"
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
grain:
|
||||||
|
"radial-gradient(circle at top left, rgba(255,255,255,0.08), transparent 35%), radial-gradient(circle at bottom right, rgba(45,212,191,0.10), transparent 20%)"
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Outfit", "Inter", "ui-sans-serif", "system-ui"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
0
test.png
Executable file → Normal file
0
test.png
Executable file → Normal file
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
9
tsconfig.node.json
Normal file
9
tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user