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:
stan44 2026-03-30 16:18:26 -05:00
parent bec620e2c6
commit 565be4e1e7
88 changed files with 20124 additions and 6126 deletions

View File

@ -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
View 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
View File

@ -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"
}
}
]
}

View File

@ -1,3 +0,0 @@
{
"github.gitAuthentication": false
}

26
.vscode/tasks.json vendored
View File

@ -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

File diff suppressed because it is too large Load Diff

36
Cargo.toml Normal file
View 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"] }

View File

@ -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"]

Binary file not shown.

Binary file not shown.

28
README.md Normal file
View 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.

View 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

View 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);
}
}

View 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

View 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
}

View 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

View 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 "),
};
}
}

View File

@ -0,0 +1,5 @@
pub mod db;
pub mod models;
pub mod scanner;
pub use db::AppDatabase;

View 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>,
}

View 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))
}

View 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

View 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));
}
}

View File

@ -0,0 +1,8 @@
[package]
name = "fbrowser-plugin-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true

View 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);
}

View File

@ -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

View File

@ -1,8 +0,0 @@
version: '3.4'
services:
fbrowser:
image: fbrowser
build:
context: .
dockerfile: ./Dockerfile

18
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View 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"
}
}

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -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

3575
paq8l.cpp

File diff suppressed because it is too large Load Diff

BIN
paq8l.exe

Binary file not shown.

1222
paq9a.cpp

File diff suppressed because it is too large Load Diff

BIN
paq9a.exe

Binary file not shown.

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

0
pypaqtest.paq Executable file → Normal file
View File

View File

@ -0,0 +1 @@
3.13

0
Fbrowser.py → python-src/Fbrowser.py Executable file → Normal file
View File

4
MidPlay.py → python-src/MidPlay.py Executable file → Normal file
View File

@ -6,11 +6,11 @@ import pygame
import mido
import fluidsynth
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,
QProgressBar,
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 cProfile # profiler remove for production

0
ScanOrg.py → python-src/ScanOrg.py Executable file → Normal file
View File

0
compression.py → python-src/archive_compression.py Executable file → Normal file
View File

0
extraction.py → python-src/extraction.py Executable file → Normal file
View File

0
paqtest.py → python-src/paqtest.py Executable file → Normal file
View File

0
readme.txt → python-src/readme.txt Executable file → Normal file
View File

View File

@ -1,12 +1,11 @@
PyQT5
PyQt5-tools
PyQt6
PyQt6-tools
matplotlib
rarfile
py7zr
pygame
mido
mutagen
numpy
crypto
django

0
stanzip.py → python-src/stanzip.py Executable file → Normal file
View File

0
test.py → python-src/test.py Executable file → Normal file
View File

0
test_Fbrowser.py → python-src/test_Fbrowser.py Executable file → Normal file
View File

22
src-tauri/Cargo.toml Normal file
View 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
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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
View 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) = &current.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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

20
tsconfig.json Normal file
View 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
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
});