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

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

@ -1,216 +1,216 @@
# Path: Fbrowser.py
# Sample Music Browser & Ogranizer: Main.py
# Importing Libraries
import sys
import os
from ScanOrg import organizer, file_scanner, DirectoryFilterProxyModel, FileFilterProxyModel
from PyQt5.QtGui import QStandardItem , QStandardItemModel
from PyQt5.QtWidgets import QApplication, QLabel, QPushButton, QTreeView, QMessageBox, QSlider, QWidget, QFileSystemModel, QSplitter, QHBoxLayout, QFileDialog
from PyQt5.QtMultimedia import QMediaPlaylist, QMediaPlayer, QMediaContent, QAudioFormat, QAudioDeviceInfo, QAudio
from PyQt5.QtCore import QDir, QSortFilterProxyModel, Qt, QUrl #QAbstractItemModel, QAbstractProxyModel, QModelIndex, QItemSelectionModel, QItemSelection, QItemSelectionRange, QItemSelectionModel, QItemSelection, QItemSelectionRange
"""
# Audio Format
audio_format = QAudioFormat()
audio_format.setSampleRate(44100)
audio_format.setChannelCount(2)
audio_format.setSampleSize(16)
audio_format.setCodec('audio/pcm')
audio_format.setByteOrder(QAudioFormat.LittleEndian)
audio_format.setSampleType(QAudioFormat.SignedInt)
# Audio Device Info
device_info = QAudioDeviceInfo.defaultOutputDevice()
if not device_info.isFormatSupported(audio_format):
print('Raw audio format not supported by backend, cannot play audio.')
audio_format = device_info.nearestFormat(audio_format)
"""
# Sample Music Browser Main Class
class SampleMusicBrowser(QWidget):
def __init__(self):
super().__init__()
self.organizer = organizer()
self.file_model = QStandardItemModel()
self.player = QMediaPlayer()
self.playlist = QMediaPlaylist()
self.player.setPlaylist(self.playlist)
self.tree_model = QFileSystemModel()
self.init_ui()
self.folder_contents_view.setEditTriggers(QTreeView.NoEditTriggers)
self.player.error.connect(self.player_error)
self.player.mediaStatusChanged.connect(self.player_media_status_changed)
self.player.setAudioRole(QAudio.MusicRole)
def player_error(self, error):
if error == QMediaPlayer.NoError:
return
print('Error: ' + self.player.errorString())
def player_media_status_changed(self, status):
if status == QMediaPlayer.NoMedia:
return
print('Media Status: ' + str(status))
def init_ui(self):
layout = QHBoxLayout()
label = QLabel('Sample Music Browser')
layout.addWidget(label)
button = QPushButton('Exit')
button.clicked.connect(self.show_exit_popup)
layout.addWidget(button)
self.file_tree = QTreeView()
self.file_tree.setHeaderHidden(True)
self.file_tree.clicked.connect(self.change_directory)
self.folder_contents_view = QTreeView()
self.folder_contents_view.setHeaderHidden(False)
self.folder_contents_view.setRootIsDecorated(False)
self.folder_contents_view.setSortingEnabled(True)
splitter = QSplitter()
splitter.addWidget(self.file_tree)
splitter.addWidget(self.folder_contents_view)
layout.addWidget(splitter)
self.current_dir_label = QLabel()
layout.addWidget(self.current_dir_label)
up_dir_button = QPushButton('Up Directory')
up_dir_button.clicked.connect(self.go_up_directory)
layout.addWidget(up_dir_button)
back_button = QPushButton('Back')
back_button.clicked.connect(self.go_back_directory)
layout.addWidget(back_button)
forward_button = QPushButton('Forward')
forward_button.clicked.connect(self.go_forward_directory)
layout.addWidget(forward_button)
self.setLayout(layout)
self.setWindowTitle('Samples are life!')
path = QFileDialog.getExistingDirectory(self, 'Select Directory')
if path:
self.populate_file_tree(path)
play_button = QPushButton('Play')
play_button.clicked.connect(self.player.play)
layout.addWidget(play_button)
pause_button = QPushButton('Pause')
pause_button.clicked.connect(self.player.pause)
layout.addWidget(pause_button)
stop_button = QPushButton('Stop')
stop_button.clicked.connect(self.player.stop)
layout.addWidget(stop_button)
self.player.stateChanged.connect(self.player_state_changed)
self.player.positionChanged.connect(self.player_position_changed)
self.player.durationChanged.connect(self.player_duration_changed)
self.player.setVolume(50)
volume_slider = QSlider(Qt.Horizontal)
volume_slider.setRange(0, 100)
volume_slider.setValue(50)
volume_slider.valueChanged.connect(self.player.setVolume)
layout.addWidget(volume_slider)
self.playlist.currentIndexChanged.connect(self.playlist_current_index_changed)
self.playlist.currentMediaChanged.connect(self.playlist_current_media_changed)
self.playlist.mediaInserted.connect(self.playlist_media_inserted)
self.playlist.mediaRemoved.connect(self.playlist_media_removed)
self.playlist.setPlaybackMode(QMediaPlaylist.Loop)
self.folder_contents_view.doubleClicked.connect(self.play_file)
def directory_loaded(self, path):
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.model.index(path)))
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
def populate_file_tree(self, path):
try:
self.tree_model.setRootPath(path)
self.file_tree.setModel(self.tree_model)
self.directory_model = DirectoryFilterProxyModel()
self.directory_model.setSourceModel(self.tree_model)
self.file_tree.setModel(self.directory_model)
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.tree_model.index(path)))
self.list_model = QFileSystemModel()
self.list_model.setRootPath(path)
self.file_filter_model = FileFilterProxyModel()
self.file_filter_model.setSourceModel(self.list_model)
self.folder_contents_view.setModel(self.file_filter_model)
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
self.current_dir_label.setText(path)
except Exception as e:
print(f"Error Populating File Tree: {e}")
def show_exit_popup(self):
reply = QMessageBox.question(self, 'Exit', 'Are you sure you want to exit?',
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
sys.exit()
def play_file(self, index):
index = self.file_filter_model.mapToSource(index)
file_path = self.list_model.filePath(index)
media = QMediaContent(QUrl.fromLocalFile(file_path))
self.playlist.addMedia(media)
self.player.play()
def player_state_changed(self, state):
if state == QMediaPlayer.StoppedState:
self.playlist.setCurrentIndex(0)
def player_position_changed(self, position):
pass
def player_duration_changed(self, duration):
pass
def playlist_current_index_changed(self, index):
pass
def playlist_current_media_changed(self, media):
pass
def playlist_media_inserted(self, start, end):
pass
def playlist_media_removed(self, start, end):
pass
def change_directory(self, index):
index = self.directory_model.mapToSource(index)
try:
file_path = self.tree_model.filePath(index)
self.list_model.setRootPath(file_path)
self.current_dir_label.setText(file_path)
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(file_path)))
except Exception as e:
print(f"Error Changing Dirs.: {e}")
def go_up_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
def go_back_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
def go_forward_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
if __name__ == '__main__':
app = QApplication(sys.argv)
sampleMusicBrowser = SampleMusicBrowser()
sampleMusicBrowser.show()
# Path: Fbrowser.py
# Sample Music Browser & Ogranizer: Main.py
# Importing Libraries
import sys
import os
from ScanOrg import organizer, file_scanner, DirectoryFilterProxyModel, FileFilterProxyModel
from PyQt5.QtGui import QStandardItem , QStandardItemModel
from PyQt5.QtWidgets import QApplication, QLabel, QPushButton, QTreeView, QMessageBox, QSlider, QWidget, QFileSystemModel, QSplitter, QHBoxLayout, QFileDialog
from PyQt5.QtMultimedia import QMediaPlaylist, QMediaPlayer, QMediaContent, QAudioFormat, QAudioDeviceInfo, QAudio
from PyQt5.QtCore import QDir, QSortFilterProxyModel, Qt, QUrl #QAbstractItemModel, QAbstractProxyModel, QModelIndex, QItemSelectionModel, QItemSelection, QItemSelectionRange, QItemSelectionModel, QItemSelection, QItemSelectionRange
"""
# Audio Format
audio_format = QAudioFormat()
audio_format.setSampleRate(44100)
audio_format.setChannelCount(2)
audio_format.setSampleSize(16)
audio_format.setCodec('audio/pcm')
audio_format.setByteOrder(QAudioFormat.LittleEndian)
audio_format.setSampleType(QAudioFormat.SignedInt)
# Audio Device Info
device_info = QAudioDeviceInfo.defaultOutputDevice()
if not device_info.isFormatSupported(audio_format):
print('Raw audio format not supported by backend, cannot play audio.')
audio_format = device_info.nearestFormat(audio_format)
"""
# Sample Music Browser Main Class
class SampleMusicBrowser(QWidget):
def __init__(self):
super().__init__()
self.organizer = organizer()
self.file_model = QStandardItemModel()
self.player = QMediaPlayer()
self.playlist = QMediaPlaylist()
self.player.setPlaylist(self.playlist)
self.tree_model = QFileSystemModel()
self.init_ui()
self.folder_contents_view.setEditTriggers(QTreeView.NoEditTriggers)
self.player.error.connect(self.player_error)
self.player.mediaStatusChanged.connect(self.player_media_status_changed)
self.player.setAudioRole(QAudio.MusicRole)
def player_error(self, error):
if error == QMediaPlayer.NoError:
return
print('Error: ' + self.player.errorString())
def player_media_status_changed(self, status):
if status == QMediaPlayer.NoMedia:
return
print('Media Status: ' + str(status))
def init_ui(self):
layout = QHBoxLayout()
label = QLabel('Sample Music Browser')
layout.addWidget(label)
button = QPushButton('Exit')
button.clicked.connect(self.show_exit_popup)
layout.addWidget(button)
self.file_tree = QTreeView()
self.file_tree.setHeaderHidden(True)
self.file_tree.clicked.connect(self.change_directory)
self.folder_contents_view = QTreeView()
self.folder_contents_view.setHeaderHidden(False)
self.folder_contents_view.setRootIsDecorated(False)
self.folder_contents_view.setSortingEnabled(True)
splitter = QSplitter()
splitter.addWidget(self.file_tree)
splitter.addWidget(self.folder_contents_view)
layout.addWidget(splitter)
self.current_dir_label = QLabel()
layout.addWidget(self.current_dir_label)
up_dir_button = QPushButton('Up Directory')
up_dir_button.clicked.connect(self.go_up_directory)
layout.addWidget(up_dir_button)
back_button = QPushButton('Back')
back_button.clicked.connect(self.go_back_directory)
layout.addWidget(back_button)
forward_button = QPushButton('Forward')
forward_button.clicked.connect(self.go_forward_directory)
layout.addWidget(forward_button)
self.setLayout(layout)
self.setWindowTitle('Samples are life!')
path = QFileDialog.getExistingDirectory(self, 'Select Directory')
if path:
self.populate_file_tree(path)
play_button = QPushButton('Play')
play_button.clicked.connect(self.player.play)
layout.addWidget(play_button)
pause_button = QPushButton('Pause')
pause_button.clicked.connect(self.player.pause)
layout.addWidget(pause_button)
stop_button = QPushButton('Stop')
stop_button.clicked.connect(self.player.stop)
layout.addWidget(stop_button)
self.player.stateChanged.connect(self.player_state_changed)
self.player.positionChanged.connect(self.player_position_changed)
self.player.durationChanged.connect(self.player_duration_changed)
self.player.setVolume(50)
volume_slider = QSlider(Qt.Horizontal)
volume_slider.setRange(0, 100)
volume_slider.setValue(50)
volume_slider.valueChanged.connect(self.player.setVolume)
layout.addWidget(volume_slider)
self.playlist.currentIndexChanged.connect(self.playlist_current_index_changed)
self.playlist.currentMediaChanged.connect(self.playlist_current_media_changed)
self.playlist.mediaInserted.connect(self.playlist_media_inserted)
self.playlist.mediaRemoved.connect(self.playlist_media_removed)
self.playlist.setPlaybackMode(QMediaPlaylist.Loop)
self.folder_contents_view.doubleClicked.connect(self.play_file)
def directory_loaded(self, path):
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.model.index(path)))
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
def populate_file_tree(self, path):
try:
self.tree_model.setRootPath(path)
self.file_tree.setModel(self.tree_model)
self.directory_model = DirectoryFilterProxyModel()
self.directory_model.setSourceModel(self.tree_model)
self.file_tree.setModel(self.directory_model)
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.tree_model.index(path)))
self.list_model = QFileSystemModel()
self.list_model.setRootPath(path)
self.file_filter_model = FileFilterProxyModel()
self.file_filter_model.setSourceModel(self.list_model)
self.folder_contents_view.setModel(self.file_filter_model)
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
self.current_dir_label.setText(path)
except Exception as e:
print(f"Error Populating File Tree: {e}")
def show_exit_popup(self):
reply = QMessageBox.question(self, 'Exit', 'Are you sure you want to exit?',
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
sys.exit()
def play_file(self, index):
index = self.file_filter_model.mapToSource(index)
file_path = self.list_model.filePath(index)
media = QMediaContent(QUrl.fromLocalFile(file_path))
self.playlist.addMedia(media)
self.player.play()
def player_state_changed(self, state):
if state == QMediaPlayer.StoppedState:
self.playlist.setCurrentIndex(0)
def player_position_changed(self, position):
pass
def player_duration_changed(self, duration):
pass
def playlist_current_index_changed(self, index):
pass
def playlist_current_media_changed(self, media):
pass
def playlist_media_inserted(self, start, end):
pass
def playlist_media_removed(self, start, end):
pass
def change_directory(self, index):
index = self.directory_model.mapToSource(index)
try:
file_path = self.tree_model.filePath(index)
self.list_model.setRootPath(file_path)
self.current_dir_label.setText(file_path)
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(file_path)))
except Exception as e:
print(f"Error Changing Dirs.: {e}")
def go_up_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
def go_back_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
def go_forward_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
if __name__ == '__main__':
app = QApplication(sys.argv)
sampleMusicBrowser = SampleMusicBrowser()
sampleMusicBrowser.show()
sys.exit(app.exec_())

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

@ -1,261 +1,261 @@
#Path: MidPlay.py
# Description: A class to play MIDI files and a class to view MIDI files
import pygame
# Imports
import mido
import fluidsynth
import os
from PyQt5.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
import threading
import cProfile # profiler remove for production
#profiler remove for production
def start_profiling():
global pr
pr = cProfile.Profile()
pr.enable()
def stop_profiling():
pr.disable()
pr.dump_stats('midi_profile.out')
# The pygame.mixer.init() call is necessary to initialize the mixer module
# before any sound can be played. The pygame.init() call is necessary maybe.
pygame.mixer.init()
pygame.init()
class MidPlayGUI(QWidget):
def __init__(self):
super().__init__()
self.player = MidPlay()
self.current_midi_label = QLabel()
self.playlist_widget = QListWidget()
self.setWindowTitle("MidPlay - Midi Player")
self.init_ui()
self.timer = QTimer()
self.timer.timeout.connect(self.handle_song_end)
self.timer.start(1000)
def set_volume(self, value):
volume = value / 100
pygame.mixer.music.set_volume(volume)
def update_progress(self):
if self.player.current_midi:
current_time = pygame.mixer.music.get_pos() / 1000 # get_pos returns time in milliseconds NOT SECONDS!
total_time = self.calculate_midi_duration(self.player.current_midi)
progress = current_time / total_time * 100
self.progress_bar.setValue(int(progress))
def calculate_midi_duration(self, midi_file):
total_duration = 0
for track in midi_file.tracks:
track_duration = max([msg.time for msg in track]) if track else 0
total_duration = max(total_duration, track_duration)
return total_duration
def handle_song_end(self):
if self.player.playing and not pygame.mixer.music.get_busy():
self.player.next_song()
if self.player.playlist:
self.player.current_index %= len(self.player.playlist)
filepath = self.player.playlist[self.player.current_index]
filename = os.path.basename(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.update_progress()
def init_ui(self):
#label = QLabel("MidPlay - Midi player")
#label.setStyleSheet("font-size: 20px; font-weight: bold;")
self.progress_bar = QProgressBar()
self.volume_slider = QSlider(Qt.Horizontal)
self.volume_slider.setMinimum(0)
self.volume_slider.setMaximum(100)
self.volume_slider.setValue(100)
self.volume_slider.valueChanged.connect(self.set_volume)
self.current_midi_label.setText("Current MIDI: None")
self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song)
pygame.mixer.music.set_endevent(pygame.USEREVENT)
# Buttons
play_button = QPushButton("Play")
play_button.clicked.connect(self.player.play_midi)
pause_button = QPushButton("Pause")
pause_button.clicked.connect(self.player.pause)
stop_button = QPushButton("Stop")
stop_button.clicked.connect(self.player.stop)
next_button = QPushButton("Next")
next_button.clicked.connect(self.player.next_song)
back_button = QPushButton("Back")
back_button.clicked.connect(self.previous_song)
add_button = QPushButton("Add to Playlist")
add_button.clicked.connect(self.load_midi_file)
add_folder_button = QPushButton("Add Folder to Playlist")
add_folder_button.clicked.connect(self.load_folder)
clear_button = QPushButton("Clear Playlist")
clear_button.clicked.connect(self.clear_playlist)
# Window layout
layout = QVBoxLayout()
layout.addWidget(self.current_midi_label)
layout.addWidget(self.playlist_widget)
layout.addWidget(self.progress_bar)
layout.addWidget(self.volume_slider)
layout.addWidget(play_button)
layout.addWidget(pause_button)
layout.addWidget(stop_button)
layout.addWidget(next_button)
layout.addWidget(back_button)
layout.addWidget(add_button)
layout.addWidget(add_folder_button)
layout.addWidget(clear_button)
progress_volume_layout = QHBoxLayout()
progress_volume_layout.addWidget(self.progress_bar)
progress_volume_layout.addWidget(self.volume_slider)
layout.addLayout(progress_volume_layout)
self.setLayout(layout)
# Event handlers
def play_selected_song(self, item):
index = self.playlist_widget.row(item)
self.current_index = index
filepath = self.player.playlist[self.current_index]
self.player.load_midi(filepath)
self.player.play_midi()
pygame.mixer.music.set_endevent(pygame.USEREVENT)
filename = os.path.basename(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
def load_midi_file(self):
filepath, _ = QFileDialog.getOpenFileName(self, "Select MIDI File", filter="MIDI files (*.mid *.midi)")
if filepath:
filename = os.path.basename(filepath)
self.player.load_midi(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.player.play_midi()
self.playlist_widget.addItem(filename)
self.player.add_to_playlist(filepath)
def load_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
if folder:
for file in os.listdir(folder):
if file.endswith((".midi", ".mid")):
filepath = os.path.join(folder, file)
self.playlist_widget.addItem(file)
self.player.add_to_playlist(filepath) # Only add to playlist, don't load immediately!!!!!!!!!!!!!!!
#probably should be in the MidPlay class
def previous_song(self):
if self.player.playlist:
filepath = self.player.playlist[self.current_index]
filename = os.path.basename(filepath)
self.player.current_index = (self.player.current_index - 1) % len(self.player.playlist)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.player.play_midi()
def clear_playlist(self):
self.player.clear_playlist()
self.playlist_widget.clear()
def closeEvent(self, event):
confirmation = QMessageBox.question(self, "Exit Confirmation", "Are you sure you want to exit?", QMessageBox.Yes | QMessageBox.No)
if confirmation == QMessageBox.Yes:
pygame.mixer.quit()
pygame.quit()
event.accept()
else:
event.ignore()
class MidPlay:
"""The Heart of Midi Playback"""
def __init__(self):
self.playlist = []
self.current_midi = None
self.playing = False
self.current_index = 0
def load_midi(self, filepath: str) -> None:
def load():
try:
self.current_midi = mido.MidiFile(filepath)
pygame.mixer.music.load(filepath)
except Exception as e:
print(f"Error loading MIDI: {e}")
threading.Thread(target=load).start()
def add_to_playlist(self, filepath: str) -> None:
self.playlist.append(filepath)
def clear_playlist(self) -> None:
self.playlist = []
def play_midi(self) -> None:
def play():
if self.current_midi:
self.current_midi.instruments[0].synthesize()
pygame.mixer.music.play()
self.playing = True
pygame.mixer.music.set_endevent(pygame.USEREVENT)
else:
print("No MIDI file loaded")
threading.Thread(target=play).start()
def pause(self) -> None:
pygame.mixer.music.pause()
self.playing = False
def stop(self) -> None:
pygame.mixer.music.stop()
self.playing = False
def next_song(self) -> None:
#print("Debug: next_song() called", self.playlist) debug line
if self.playlist:
self.current_index = (self.current_index + 1) % len(self.playlist)
filepath = self.playlist[self.current_index]
# If a new MIDI was loaded before the last one ended, respect that as the new playlist start
if self.current_midi and self.playing:
# print("Debug: New MIDI loaded before last one ended") # debug line
self.load_midi(filepath)
self.play_midi()
# print("Debug: Filepath:", filepath) # debug line
# print("Debug: Current MIDI:", self.current_midi) # debug line
"""
if __name__ == '__main__':
app = QApplication([])
player_gui = MidPlayGUI()
player_gui.show()
running = True
while True:
for event in pygame.event.get():
if event.type == pygame.USEREVENT:
player_gui.player.next_song()
if event.type == pygame.QUIT:
running = False
break
app.exec_()
#Path: MidPlay.py
# Description: A class to play MIDI files and a class to view MIDI files
import pygame
# Imports
import mido
import fluidsynth
import os
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 PyQt6.QtCore import QTimer, Qt
import threading
import cProfile # profiler remove for production
#profiler remove for production
def start_profiling():
global pr
pr = cProfile.Profile()
pr.enable()
def stop_profiling():
pr.disable()
pr.dump_stats('midi_profile.out')
# The pygame.mixer.init() call is necessary to initialize the mixer module
# before any sound can be played. The pygame.init() call is necessary maybe.
pygame.mixer.init()
pygame.init()
class MidPlayGUI(QWidget):
def __init__(self):
super().__init__()
self.player = MidPlay()
self.current_midi_label = QLabel()
self.playlist_widget = QListWidget()
self.setWindowTitle("MidPlay - Midi Player")
self.init_ui()
self.timer = QTimer()
self.timer.timeout.connect(self.handle_song_end)
self.timer.start(1000)
def set_volume(self, value):
volume = value / 100
pygame.mixer.music.set_volume(volume)
def update_progress(self):
if self.player.current_midi:
current_time = pygame.mixer.music.get_pos() / 1000 # get_pos returns time in milliseconds NOT SECONDS!
total_time = self.calculate_midi_duration(self.player.current_midi)
progress = current_time / total_time * 100
self.progress_bar.setValue(int(progress))
def calculate_midi_duration(self, midi_file):
total_duration = 0
for track in midi_file.tracks:
track_duration = max([msg.time for msg in track]) if track else 0
total_duration = max(total_duration, track_duration)
return total_duration
def handle_song_end(self):
if self.player.playing and not pygame.mixer.music.get_busy():
self.player.next_song()
if self.player.playlist:
self.player.current_index %= len(self.player.playlist)
filepath = self.player.playlist[self.player.current_index]
filename = os.path.basename(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.update_progress()
def init_ui(self):
#label = QLabel("MidPlay - Midi player")
#label.setStyleSheet("font-size: 20px; font-weight: bold;")
self.progress_bar = QProgressBar()
self.volume_slider = QSlider(Qt.Horizontal)
self.volume_slider.setMinimum(0)
self.volume_slider.setMaximum(100)
self.volume_slider.setValue(100)
self.volume_slider.valueChanged.connect(self.set_volume)
self.current_midi_label.setText("Current MIDI: None")
self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song)
pygame.mixer.music.set_endevent(pygame.USEREVENT)
# Buttons
play_button = QPushButton("Play")
play_button.clicked.connect(self.player.play_midi)
pause_button = QPushButton("Pause")
pause_button.clicked.connect(self.player.pause)
stop_button = QPushButton("Stop")
stop_button.clicked.connect(self.player.stop)
next_button = QPushButton("Next")
next_button.clicked.connect(self.player.next_song)
back_button = QPushButton("Back")
back_button.clicked.connect(self.previous_song)
add_button = QPushButton("Add to Playlist")
add_button.clicked.connect(self.load_midi_file)
add_folder_button = QPushButton("Add Folder to Playlist")
add_folder_button.clicked.connect(self.load_folder)
clear_button = QPushButton("Clear Playlist")
clear_button.clicked.connect(self.clear_playlist)
# Window layout
layout = QVBoxLayout()
layout.addWidget(self.current_midi_label)
layout.addWidget(self.playlist_widget)
layout.addWidget(self.progress_bar)
layout.addWidget(self.volume_slider)
layout.addWidget(play_button)
layout.addWidget(pause_button)
layout.addWidget(stop_button)
layout.addWidget(next_button)
layout.addWidget(back_button)
layout.addWidget(add_button)
layout.addWidget(add_folder_button)
layout.addWidget(clear_button)
progress_volume_layout = QHBoxLayout()
progress_volume_layout.addWidget(self.progress_bar)
progress_volume_layout.addWidget(self.volume_slider)
layout.addLayout(progress_volume_layout)
self.setLayout(layout)
# Event handlers
def play_selected_song(self, item):
index = self.playlist_widget.row(item)
self.current_index = index
filepath = self.player.playlist[self.current_index]
self.player.load_midi(filepath)
self.player.play_midi()
pygame.mixer.music.set_endevent(pygame.USEREVENT)
filename = os.path.basename(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
def load_midi_file(self):
filepath, _ = QFileDialog.getOpenFileName(self, "Select MIDI File", filter="MIDI files (*.mid *.midi)")
if filepath:
filename = os.path.basename(filepath)
self.player.load_midi(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.player.play_midi()
self.playlist_widget.addItem(filename)
self.player.add_to_playlist(filepath)
def load_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
if folder:
for file in os.listdir(folder):
if file.endswith((".midi", ".mid")):
filepath = os.path.join(folder, file)
self.playlist_widget.addItem(file)
self.player.add_to_playlist(filepath) # Only add to playlist, don't load immediately!!!!!!!!!!!!!!!
#probably should be in the MidPlay class
def previous_song(self):
if self.player.playlist:
filepath = self.player.playlist[self.current_index]
filename = os.path.basename(filepath)
self.player.current_index = (self.player.current_index - 1) % len(self.player.playlist)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.player.play_midi()
def clear_playlist(self):
self.player.clear_playlist()
self.playlist_widget.clear()
def closeEvent(self, event):
confirmation = QMessageBox.question(self, "Exit Confirmation", "Are you sure you want to exit?", QMessageBox.Yes | QMessageBox.No)
if confirmation == QMessageBox.Yes:
pygame.mixer.quit()
pygame.quit()
event.accept()
else:
event.ignore()
class MidPlay:
"""The Heart of Midi Playback"""
def __init__(self):
self.playlist = []
self.current_midi = None
self.playing = False
self.current_index = 0
def load_midi(self, filepath: str) -> None:
def load():
try:
self.current_midi = mido.MidiFile(filepath)
pygame.mixer.music.load(filepath)
except Exception as e:
print(f"Error loading MIDI: {e}")
threading.Thread(target=load).start()
def add_to_playlist(self, filepath: str) -> None:
self.playlist.append(filepath)
def clear_playlist(self) -> None:
self.playlist = []
def play_midi(self) -> None:
def play():
if self.current_midi:
self.current_midi.instruments[0].synthesize()
pygame.mixer.music.play()
self.playing = True
pygame.mixer.music.set_endevent(pygame.USEREVENT)
else:
print("No MIDI file loaded")
threading.Thread(target=play).start()
def pause(self) -> None:
pygame.mixer.music.pause()
self.playing = False
def stop(self) -> None:
pygame.mixer.music.stop()
self.playing = False
def next_song(self) -> None:
#print("Debug: next_song() called", self.playlist) debug line
if self.playlist:
self.current_index = (self.current_index + 1) % len(self.playlist)
filepath = self.playlist[self.current_index]
# If a new MIDI was loaded before the last one ended, respect that as the new playlist start
if self.current_midi and self.playing:
# print("Debug: New MIDI loaded before last one ended") # debug line
self.load_midi(filepath)
self.play_midi()
# print("Debug: Filepath:", filepath) # debug line
# print("Debug: Current MIDI:", self.current_midi) # debug line
"""
if __name__ == '__main__':
app = QApplication([])
player_gui = MidPlayGUI()
player_gui.show()
running = True
while True:
for event in pygame.event.get():
if event.type == pygame.USEREVENT:
player_gui.player.next_song()
if event.type == pygame.QUIT:
running = False
break
app.exec_()
"""

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

@ -1,187 +1,187 @@
#Path: ScanOrg.py
# Description: A class to scan and organize music files
import concurrent.futures
import threading
import queue
import zipfile
import py7zr
import rarfile
import os
import mutagen
from PyQt6.QtCore import Qt, QSortFilterProxyModel, pyqtSignal
# Directory Filter Proxy Model
class DirectoryFilterProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.setFilterKeyColumn(0)
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
return self.sourceModel().isDir(index)
# File Filter Proxy Model
class FileFilterProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.setFilterKeyColumn(0)
self.allowed_extensions = ['.zip', '.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a']
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
if self.sourceModel().isDir(index):
return True
else:
return self.sourceModel().fileName(index).endswith(tuple(self.allowed_extensions))
# File Scan and Organize
class file_scanner:
def __init__(self):
self.file_list = []
self.cache = {}
def scan(self, path):
def background_scan(self, path):
if path in self.cache:
return self.cache[path]
file_list = []
dirs_queue = queue.Queue()
dirs_queue.put(path)
while not dirs_queue.empty():
current_path = dirs_queue.get()
try:
for root, dirs, files in os.walk(current_path):
for dir in dirs:
dirs_queue.put(os.path.join(root, dir))
for file in files:
if file.endswith(('.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a')):
file_list.append(os.path.join(root, file))
self.cache[current_path] = file_list
except (IOError, PermissionError, FileNotFoundError, OSError) as e:
print(f"Error Scanning Files: {e}")
return file_list
file_list = []
thread = threading.Thread(target=background_scan, args=(path, file_list))
thread.start()
return file_list
def get_file_list(self):
return self.file_list
def clear_file_list(self):
self.file_list = []
class extractor:
def zipviewer(self, index, file_filter_model, list_model, extraction_directory):
if index.isValid() and extraction_directory is not None:
index = file_filter_model.mapToSource(index)
file_path = list_model.filePath(index)
try:
if file_path.endswith(('.zip', '.rar', '.7z')):
with zipfile.ZipFile(file_path, 'r') as zip_ref:
for filename in zip_ref.namelist():
destination = os.path.join(extraction_directory, filename)
zip_ref.extract(filename, extraction_directory)
except (zipfile.BadZipFile, OSError, zipfile.LargeZipFile, zipfile.LargeZipFile) as e:
print(f"Extraction Error: {e}")
return
class organizer:
global metadata_queue
metadata_queue = queue.Queue()
def __init__(self):
self.file_list = []
self.artist_list = []
self.album_list = []
self.genre_list = []
self.year_list = []
self.file_scanner = file_scanner()
self.file_info_cache = {}
def scan(self, path):
if path in self.file_scanner.cache:
self.file_list = self.file_scanner.cache[path]
else:
self.file_list = self.file_scanner.scan(path)
def get_file_list(self):
return self.file_list
def clear_file_list(self):
self.file_list = []
def get_artist_list(self):
return self.artist_list
def get_album_list(self):
return self.album_list
def get_genre_list(self):
return self.genre_list
def get_year_list(self):
return self.year_list
def clear_artist_list(self):
self.artist_list = []
def clear_album_list(self):
self.album_list = []
def clear_genre_list(self):
self.genre_list = []
def clear_year_list(self):
self.year_list = []
def organize(self):
results_queue = queue.Queue()
metadata = pyqtSignal(dict)
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for file in self.file_list:
futures.append(executor.submit(self.get_file_info, file, results_queue))
for future in concurrent.futures.as_completed(futures):
try:
metadata = future.result()
if metadata['artist'] not in self.artist_list:
self.artist_list.append(metadata['artist'])
if metadata['album'] not in self.album_list:
self.album_list.append(metadata['album'])
if metadata['genre'] not in self.genre_list:
self.genre_list.append(metadata['genre'])
if metadata['year'] not in self.year_list:
self.year_list.append(metadata['year'])
except mutagen.mp3.HeaderNotFoundError:
print('Error: ' + file)
continue
while not metadata_queue.put(metadata):
pass
def get_file_info(self, file, results_queue):
try:
audio = mutagen.File(file)
artist = audio['artist'][0]
album = audio['album'][0]
genre = audio['genre'][0]
year = audio['date'][0]
if artist not in self.artist_list:
self.artist_list.append(artist)
if album not in self.album_list:
self.album_list.append(album)
if genre not in self.genre_list:
self.genre_list.append(genre)
if year not in self.year_list:
self.year_list.append(year)
metadata = {
'artist': artist,
'album': album,
'genre': genre,
'year': year
}
self.metadata_extracted.emit(metadata)
except Exception as e:
results_queue.put(None)
print('Error: ' + file)
if os.path.splitext(file)[1] == ('.mp3', '.wav', '.flac', '.m4a', '.wma', 'mid', '.midi'):
self.organize_audio()
#Path: ScanOrg.py
# Description: A class to scan and organize music files
import concurrent.futures
import threading
import queue
import zipfile
import py7zr
import rarfile
import os
import mutagen
from PyQt6.QtCore import Qt, QSortFilterProxyModel, pyqtSignal
# Directory Filter Proxy Model
class DirectoryFilterProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.setFilterKeyColumn(0)
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
return self.sourceModel().isDir(index)
# File Filter Proxy Model
class FileFilterProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.setFilterKeyColumn(0)
self.allowed_extensions = ['.zip', '.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a']
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
if self.sourceModel().isDir(index):
return True
else:
return self.sourceModel().fileName(index).endswith(tuple(self.allowed_extensions))
# File Scan and Organize
class file_scanner:
def __init__(self):
self.file_list = []
self.cache = {}
def scan(self, path):
def background_scan(self, path):
if path in self.cache:
return self.cache[path]
file_list = []
dirs_queue = queue.Queue()
dirs_queue.put(path)
while not dirs_queue.empty():
current_path = dirs_queue.get()
try:
for root, dirs, files in os.walk(current_path):
for dir in dirs:
dirs_queue.put(os.path.join(root, dir))
for file in files:
if file.endswith(('.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a')):
file_list.append(os.path.join(root, file))
self.cache[current_path] = file_list
except (IOError, PermissionError, FileNotFoundError, OSError) as e:
print(f"Error Scanning Files: {e}")
return file_list
file_list = []
thread = threading.Thread(target=background_scan, args=(path, file_list))
thread.start()
return file_list
def get_file_list(self):
return self.file_list
def clear_file_list(self):
self.file_list = []
class extractor:
def zipviewer(self, index, file_filter_model, list_model, extraction_directory):
if index.isValid() and extraction_directory is not None:
index = file_filter_model.mapToSource(index)
file_path = list_model.filePath(index)
try:
if file_path.endswith(('.zip', '.rar', '.7z')):
with zipfile.ZipFile(file_path, 'r') as zip_ref:
for filename in zip_ref.namelist():
destination = os.path.join(extraction_directory, filename)
zip_ref.extract(filename, extraction_directory)
except (zipfile.BadZipFile, OSError, zipfile.LargeZipFile, zipfile.LargeZipFile) as e:
print(f"Extraction Error: {e}")
return
class organizer:
global metadata_queue
metadata_queue = queue.Queue()
def __init__(self):
self.file_list = []
self.artist_list = []
self.album_list = []
self.genre_list = []
self.year_list = []
self.file_scanner = file_scanner()
self.file_info_cache = {}
def scan(self, path):
if path in self.file_scanner.cache:
self.file_list = self.file_scanner.cache[path]
else:
self.file_list = self.file_scanner.scan(path)
def get_file_list(self):
return self.file_list
def clear_file_list(self):
self.file_list = []
def get_artist_list(self):
return self.artist_list
def get_album_list(self):
return self.album_list
def get_genre_list(self):
return self.genre_list
def get_year_list(self):
return self.year_list
def clear_artist_list(self):
self.artist_list = []
def clear_album_list(self):
self.album_list = []
def clear_genre_list(self):
self.genre_list = []
def clear_year_list(self):
self.year_list = []
def organize(self):
results_queue = queue.Queue()
metadata = pyqtSignal(dict)
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for file in self.file_list:
futures.append(executor.submit(self.get_file_info, file, results_queue))
for future in concurrent.futures.as_completed(futures):
try:
metadata = future.result()
if metadata['artist'] not in self.artist_list:
self.artist_list.append(metadata['artist'])
if metadata['album'] not in self.album_list:
self.album_list.append(metadata['album'])
if metadata['genre'] not in self.genre_list:
self.genre_list.append(metadata['genre'])
if metadata['year'] not in self.year_list:
self.year_list.append(metadata['year'])
except mutagen.mp3.HeaderNotFoundError:
print('Error: ' + file)
continue
while not metadata_queue.put(metadata):
pass
def get_file_info(self, file, results_queue):
try:
audio = mutagen.File(file)
artist = audio['artist'][0]
album = audio['album'][0]
genre = audio['genre'][0]
year = audio['date'][0]
if artist not in self.artist_list:
self.artist_list.append(artist)
if album not in self.album_list:
self.album_list.append(album)
if genre not in self.genre_list:
self.genre_list.append(genre)
if year not in self.year_list:
self.year_list.append(year)
metadata = {
'artist': artist,
'album': album,
'genre': genre,
'year': year
}
self.metadata_extracted.emit(metadata)
except Exception as e:
results_queue.put(None)
print('Error: ' + file)
if os.path.splitext(file)[1] == ('.mp3', '.wav', '.flac', '.m4a', '.wma', 'mid', '.midi'):
self.organize_audio()
audio = mutagen.File(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

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

@ -1,9 +1,9 @@
<<<<<<< HEAD
FBroswer is a sample and loop organizer and browser with extra features
such as the ability to sample that audio you're checking out
full midi play back support with soundfont controls
a timer to help balance work life and personal life
a way to keep track of what you're listening to
=======
fbrowser is a sample and loops audio organizer and browser
>>>>>>> 1ee9caf82243dd45a72a97bf6c5de681139670e2
<<<<<<< HEAD
FBroswer is a sample and loop organizer and browser with extra features
such as the ability to sample that audio you're checking out
full midi play back support with soundfont controls
a timer to help balance work life and personal life
a way to keep track of what you're listening to
=======
fbrowser is a sample and loops audio organizer and browser
>>>>>>> 1ee9caf82243dd45a72a97bf6c5de681139670e2

View File

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

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

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

@ -1,168 +1,168 @@
import mido as pretty_midi
import random
import tkinter as tk
from tkinter import ttk, filedialog
import pygame
import pypianoroll # type: ignore
from icecream import ic # type: ignore
class midgen:
def __init__(self, status_label: ttk.Label):
self.status_label = status_label
self.scales = self.scales()
def scales(self):
scales = {
"Major": [0, 2, 4, 5, 7, 9, 11],
"Minor": [0, 2, 3, 5, 7, 8, 10],
"Pentatonic": [0, 2, 4, 7, 9],
"Blues": [0, 3, 5, 6, 7, 10],
"Whole Tone": [0, 2, 4, 6, 8, 10],
"Chromatic": [i for i in range(12)],
"Octatonic": [0, 1, 3, 4, 6, 7, 9, 10],
"Harmonic Minor": [0, 2, 3, 5, 7, 8, 11],
"Melodic Minor": [0, 2, 3, 5, 7, 9, 11],
"Dorian": [0, 2, 3, 5, 7, 9, 10],
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
"Lydian": [0, 2, 4, 6, 7, 9, 11],
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
"Locrian": [0, 1, 3, 5, 6, 8, 10],
"Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
"Whole Half Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
"Arabian": [0, 2, 4, 5, 6, 8, 10],
"Hungarian Minor": [0, 2, 3, 6, 7, 8, 11],
"Enigmatic": [0, 1, 4, 6, 8, 10, 11],
"Neapolitan Major": [0, 1, 3, 5, 7, 9, 11],
"Neapolitan Minor": [0, 1, 3, 5, 7, 8, 11],
"Bluesy": [0, 3, 5, 6, 7, 10],
"Hawaiian": [0, 2, 3, 7, 9],
"Japanese": [0, 1, 5, 7, 8],
"Chinese": [0, 4, 6, 7, 11],
"Gypsy": [0, 2, 3, 6, 7, 8, 10],
"Hirojoshi": [0, 2, 3, 7, 8],
"In Sen": [0, 1, 5, 7, 10],
"Iwato": [0, 1, 5, 6, 10],
"Kumoi": [0, 2, 3, 7, 9],
"Pelog": [0, 1, 3, 7, 8],
"Ryukyu": [0, 4, 5, 7, 11],
"Spanish": [0, 1, 3, 4, 5, 6, 8, 10],
"Todi": [0, 1, 3, 6, 7, 8, 11],
"Yo": [0, 2, 5, 7, 9]
}
return scales
def generate_midi(self):
self.status_label.config(text='Generating MIDI...')
try:
midi = pretty_midi.PrettyMIDI()
instrument = pretty_midi.Instrument(0)
scale = random.choice(list(self.scales.keys()))
scale_notes = self.scales[scale]
ic(f"Using scale: {scale}")
ic(f"Using notes: {scale_notes}")
for start, end in zip(range(0, 100, 10), range(10, 110, 10)):
note = pretty_midi.Note(
velocity=100, pitch=random.choice(scale_notes),
start=start, end=end
)
instrument.notes.append(note)
midi.instruments.append(instrument)
filepath = filedialog.asksaveasfilename(defaultextension='.mid')
if filepath:
midi.write(filepath)
track = pypianoroll.Multitrack(filepath)
track.plot()
self.status_label.config(text='MIDI generated successfully!')
except Exception as e:
self.status_label.config(text=f"Error generating MIDI: {e}")
class MidPlay:
"""A class to handle MIDI file playback."""
def __init__(self):
self.playlist = []
self.current_midi = None
self.playing = False
pygame.mixer.init()
def load_midi(self, filepath: str) -> None:
try:
self.current_midi = pretty_midi.PrettyMIDI(filepath)
pygame.mixer.music.load(filepath)
except Exception as e:
print(f"Error loading MIDI: {e}")
def add_to_playlist(self, filepath: str) -> None:
"""Adds a MIDI file to the playlist.
Args:
filepath: The path to the MIDI file.
"""
self.playlist.append(filepath)
def clear_playlist(self) -> None:
"""Clears the playlist."""
self.playlist = []
def play_midi(self) -> None:
"""Starts or resumes playback of the current MIDI file."""
if self.current_midi:
self.current_midi.instruments[0].synthesize()
pygame.mixer.music.play()
self.playing = True
else:
print("No MIDI file loaded")
def pause(self) -> None:
"""Pauses playback."""
pygame.mixer.music.pause()
self.playing = False
def stop(self) -> None:
"""Stops playback."""
pygame.mixer.music.stop()
self.playing = False
class UserInterface:
def __init__(self):
self.root = tk.Tk()
self.root.title("MIDI Generator")
self.root.geometry("400x200")
self.root.resizable(True, True)
self.status_label = ttk.Label(self.root, text="")
self.status_label.pack()
self.midi_generator = midgen(self.status_label)
self.midi_player = MidPlay()
self.filepath = None
self.midi = None
self.generate_button = ttk.Button(self.root, text="Generate MIDI", command=self.midi_generator.generate_midi)
self.generate_button.pack()
self.load_button = ttk.Button(self.root, text="Load MIDI", command=lambda: self.midi_player.load_midi(self.filepath))
self.load_button.pack()
self.play_button = ttk.Button(self.root, text="Play MIDI", command=lambda: self.midi_player.play_midi())
self.play_button.pack()
self.exit_button = ttk.Button(self.root, text="Exit", command=self.root.quit)
self.exit_button.pack()
window = tk.Tk()
window.title("MIDI Generator")
self.root.mainloop()
if __name__ == "__main__":
ui = UserInterface()
import mido as pretty_midi
import random
import tkinter as tk
from tkinter import ttk, filedialog
import pygame
import pypianoroll # type: ignore
from icecream import ic # type: ignore
class midgen:
def __init__(self, status_label: ttk.Label):
self.status_label = status_label
self.scales = self.scales()
def scales(self):
scales = {
"Major": [0, 2, 4, 5, 7, 9, 11],
"Minor": [0, 2, 3, 5, 7, 8, 10],
"Pentatonic": [0, 2, 4, 7, 9],
"Blues": [0, 3, 5, 6, 7, 10],
"Whole Tone": [0, 2, 4, 6, 8, 10],
"Chromatic": [i for i in range(12)],
"Octatonic": [0, 1, 3, 4, 6, 7, 9, 10],
"Harmonic Minor": [0, 2, 3, 5, 7, 8, 11],
"Melodic Minor": [0, 2, 3, 5, 7, 9, 11],
"Dorian": [0, 2, 3, 5, 7, 9, 10],
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
"Lydian": [0, 2, 4, 6, 7, 9, 11],
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
"Locrian": [0, 1, 3, 5, 6, 8, 10],
"Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
"Whole Half Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
"Arabian": [0, 2, 4, 5, 6, 8, 10],
"Hungarian Minor": [0, 2, 3, 6, 7, 8, 11],
"Enigmatic": [0, 1, 4, 6, 8, 10, 11],
"Neapolitan Major": [0, 1, 3, 5, 7, 9, 11],
"Neapolitan Minor": [0, 1, 3, 5, 7, 8, 11],
"Bluesy": [0, 3, 5, 6, 7, 10],
"Hawaiian": [0, 2, 3, 7, 9],
"Japanese": [0, 1, 5, 7, 8],
"Chinese": [0, 4, 6, 7, 11],
"Gypsy": [0, 2, 3, 6, 7, 8, 10],
"Hirojoshi": [0, 2, 3, 7, 8],
"In Sen": [0, 1, 5, 7, 10],
"Iwato": [0, 1, 5, 6, 10],
"Kumoi": [0, 2, 3, 7, 9],
"Pelog": [0, 1, 3, 7, 8],
"Ryukyu": [0, 4, 5, 7, 11],
"Spanish": [0, 1, 3, 4, 5, 6, 8, 10],
"Todi": [0, 1, 3, 6, 7, 8, 11],
"Yo": [0, 2, 5, 7, 9]
}
return scales
def generate_midi(self):
self.status_label.config(text='Generating MIDI...')
try:
midi = pretty_midi.PrettyMIDI()
instrument = pretty_midi.Instrument(0)
scale = random.choice(list(self.scales.keys()))
scale_notes = self.scales[scale]
ic(f"Using scale: {scale}")
ic(f"Using notes: {scale_notes}")
for start, end in zip(range(0, 100, 10), range(10, 110, 10)):
note = pretty_midi.Note(
velocity=100, pitch=random.choice(scale_notes),
start=start, end=end
)
instrument.notes.append(note)
midi.instruments.append(instrument)
filepath = filedialog.asksaveasfilename(defaultextension='.mid')
if filepath:
midi.write(filepath)
track = pypianoroll.Multitrack(filepath)
track.plot()
self.status_label.config(text='MIDI generated successfully!')
except Exception as e:
self.status_label.config(text=f"Error generating MIDI: {e}")
class MidPlay:
"""A class to handle MIDI file playback."""
def __init__(self):
self.playlist = []
self.current_midi = None
self.playing = False
pygame.mixer.init()
def load_midi(self, filepath: str) -> None:
try:
self.current_midi = pretty_midi.PrettyMIDI(filepath)
pygame.mixer.music.load(filepath)
except Exception as e:
print(f"Error loading MIDI: {e}")
def add_to_playlist(self, filepath: str) -> None:
"""Adds a MIDI file to the playlist.
Args:
filepath: The path to the MIDI file.
"""
self.playlist.append(filepath)
def clear_playlist(self) -> None:
"""Clears the playlist."""
self.playlist = []
def play_midi(self) -> None:
"""Starts or resumes playback of the current MIDI file."""
if self.current_midi:
self.current_midi.instruments[0].synthesize()
pygame.mixer.music.play()
self.playing = True
else:
print("No MIDI file loaded")
def pause(self) -> None:
"""Pauses playback."""
pygame.mixer.music.pause()
self.playing = False
def stop(self) -> None:
"""Stops playback."""
pygame.mixer.music.stop()
self.playing = False
class UserInterface:
def __init__(self):
self.root = tk.Tk()
self.root.title("MIDI Generator")
self.root.geometry("400x200")
self.root.resizable(True, True)
self.status_label = ttk.Label(self.root, text="")
self.status_label.pack()
self.midi_generator = midgen(self.status_label)
self.midi_player = MidPlay()
self.filepath = None
self.midi = None
self.generate_button = ttk.Button(self.root, text="Generate MIDI", command=self.midi_generator.generate_midi)
self.generate_button.pack()
self.load_button = ttk.Button(self.root, text="Load MIDI", command=lambda: self.midi_player.load_midi(self.filepath))
self.load_button.pack()
self.play_button = ttk.Button(self.root, text="Play MIDI", command=lambda: self.midi_player.play_midi())
self.play_button.pack()
self.exit_button = ttk.Button(self.root, text="Exit", command=self.root.quit)
self.exit_button.pack()
window = tk.Tk()
window.title("MIDI Generator")
self.root.mainloop()
if __name__ == "__main__":
ui = UserInterface()

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

@ -1,37 +1,37 @@
import unittest
from unittest.mock import MagicMock
from PyQt5.QtWidgets import QApplication
from Fbrowser import SampleMusicBrowser
class TestSampleMusicBrowser(unittest.TestCase):
def setUp(self):
self.app = QApplication([])
self.browser = SampleMusicBrowser()
def tearDown(self):
self.app.quit()
def test_player_error(self):
# Mock QMediaPlayer and set error code
self.browser.player.error = MagicMock(return_value=1)
self.browser.player.errorString = MagicMock(return_value="Test Error")
self.browser.player_error(1)
# Assert that the error message is printed
self.assertIn("An error occurred: Code:1 Test Error", self.browser.console_output)
def test_player_media_status_changed(self):
# Mock QMediaPlayer and set media status
self.browser.player_media_status_changed(2)
# Assert that the media status is printed
self.assertIn("Media Status: 2", self.browser.console_output)
def test_play_file(self):
# Mock QFileSystemModel and set file path
self.browser.list_model.filePath = MagicMock(return_value="/path/to/file.mp3")
# Call play_file method
self.browser.play_file(None)
# Assert that the player is playing the correct media
self.assertEqual(self.browser.playlist.media(0).canonicalUrl().toString(), "file:///path/to/file.mp3")
if __name__ == '__main__':
import unittest
from unittest.mock import MagicMock
from PyQt5.QtWidgets import QApplication
from Fbrowser import SampleMusicBrowser
class TestSampleMusicBrowser(unittest.TestCase):
def setUp(self):
self.app = QApplication([])
self.browser = SampleMusicBrowser()
def tearDown(self):
self.app.quit()
def test_player_error(self):
# Mock QMediaPlayer and set error code
self.browser.player.error = MagicMock(return_value=1)
self.browser.player.errorString = MagicMock(return_value="Test Error")
self.browser.player_error(1)
# Assert that the error message is printed
self.assertIn("An error occurred: Code:1 Test Error", self.browser.console_output)
def test_player_media_status_changed(self):
# Mock QMediaPlayer and set media status
self.browser.player_media_status_changed(2)
# Assert that the media status is printed
self.assertIn("Media Status: 2", self.browser.console_output)
def test_play_file(self):
# Mock QFileSystemModel and set file path
self.browser.list_model.filePath = MagicMock(return_value="/path/to/file.mp3")
# Call play_file method
self.browser.play_file(None)
# Assert that the player is playing the correct media
self.assertEqual(self.browser.playlist.media(0).canonicalUrl().toString(), "file:///path/to/file.mp3")
if __name__ == '__main__':
unittest.main()

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