feat(frontend): use Wails bindings for file I/O
Replace hidden file inputs and anchor downloads with native open/save dialogs via a thin platform layer. WAV export sends base64 audio data to the Go backend for writing to the user-chosen path. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -102,11 +103,16 @@ func (s *FileService) SaveProject(content string, defaultName string) error {
|
|||||||
return os.WriteFile(path, []byte(content), 0o644)
|
return os.WriteFile(path, []byte(content), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileService) SaveAudio(data []byte, defaultName string) error {
|
func (s *FileService) SaveAudio(dataBase64 string, defaultName string) error {
|
||||||
if defaultName == "" {
|
if defaultName == "" {
|
||||||
defaultName = "keyboard_track.wav"
|
defaultName = "keyboard_track.wav"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(dataBase64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
path, err := s.app.Dialog.SaveFile().
|
path, err := s.app.Dialog.SaveFile().
|
||||||
SetMessage("Export Audio").
|
SetMessage("Export Audio").
|
||||||
SetFilename(defaultName).
|
SetFilename(defaultName).
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export function OpenVideo(): $CancellablePromise<$models.VideoResult> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveAudio(data: string, defaultName: string): $CancellablePromise<void> {
|
export function SaveAudio(dataBase64: string, defaultName: string): $CancellablePromise<void> {
|
||||||
return $Call.ByID(1785178400, data, defaultName);
|
return $Call.ByID(1785178400, dataBase64, defaultName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveProject(content: string, defaultName: string): $CancellablePromise<void> {
|
export function SaveProject(content: string, defaultName: string): $CancellablePromise<void> {
|
||||||
|
|||||||
Generated
+45
-3
@@ -11,6 +11,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tsconfig/svelte": "^5.0.8",
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
|
"@wailsio/runtime": "^3.0.0-alpha.95",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^4.4.8",
|
"svelte-check": "^4.4.8",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -330,6 +331,40 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||||
@@ -454,6 +489,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@wailsio/runtime": {
|
||||||
|
"version": "3.0.0-alpha.95",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.95.tgz",
|
||||||
|
"integrity": "sha512-ODhaZYRrtRvwi6cfGEj4L+Ut9od9zTd7GH1BrAaONxlBYM4d31d3YG982HSZiktmIE2SqvyhTciY4sV5FbIfqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tsconfig/svelte": "^5.0.8",
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
|
"@wailsio/runtime": "^3.0.0-alpha.95",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^4.4.8",
|
"svelte-check": "^4.4.8",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
|||||||
+17
-21
@@ -10,6 +10,7 @@
|
|||||||
shouldRecordMarker,
|
shouldRecordMarker,
|
||||||
} from './lib/keyboard';
|
} from './lib/keyboard';
|
||||||
import { downloadProject, exportAudio, loadProject } from './lib/project';
|
import { downloadProject, exportAudio, loadProject } from './lib/project';
|
||||||
|
import { openProject, openVideo } from './lib/platform/files';
|
||||||
import {
|
import {
|
||||||
addMarker,
|
addMarker,
|
||||||
app,
|
app,
|
||||||
@@ -28,8 +29,6 @@
|
|||||||
import { attachVideoPlayer, type VideoController } from './lib/video/player';
|
import { attachVideoPlayer, type VideoController } from './lib/video/player';
|
||||||
|
|
||||||
let videoEl: HTMLVideoElement | undefined = $state();
|
let videoEl: HTMLVideoElement | undefined = $state();
|
||||||
let videoInput: HTMLInputElement | undefined = $state();
|
|
||||||
let projectInput: HTMLInputElement | undefined = $state();
|
|
||||||
let timeline: Timeline | undefined = $state();
|
let timeline: Timeline | undefined = $state();
|
||||||
let controller: VideoController | null = null;
|
let controller: VideoController | null = null;
|
||||||
let audioEngine = createAudioEngine();
|
let audioEngine = createAudioEngine();
|
||||||
@@ -160,28 +159,27 @@
|
|||||||
audioEngine.destroy();
|
audioEngine.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleVideoFile(event: Event) {
|
async function handleOpenVideo() {
|
||||||
const input = event.target as HTMLInputElement;
|
try {
|
||||||
const file = input.files?.[0];
|
const result = await openVideo();
|
||||||
if (!file) return;
|
if (!result) return;
|
||||||
setVideoUrl(URL.createObjectURL(file), file.name);
|
setVideoUrl(result.url, result.name);
|
||||||
statusMessage = `Loaded video: ${file.name}`;
|
statusMessage = `Loaded video: ${result.name}`;
|
||||||
input.value = '';
|
} catch (error) {
|
||||||
|
statusMessage = error instanceof Error ? error.message : 'Failed to open video';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleProjectFile(event: Event) {
|
async function handleOpenProject() {
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await openProject();
|
||||||
|
if (!text) return;
|
||||||
loadProject(JSON.parse(text));
|
loadProject(JSON.parse(text));
|
||||||
statusMessage = `Loaded project: ${file.name}`;
|
statusMessage = 'Loaded project';
|
||||||
rescheduleAudio();
|
rescheduleAudio();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusMessage = error instanceof Error ? error.message : 'Failed to load project';
|
statusMessage = error instanceof Error ? error.message : 'Failed to load project';
|
||||||
}
|
}
|
||||||
input.value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportAudio() {
|
async function handleExportAudio() {
|
||||||
@@ -202,9 +200,9 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="toolbar">
|
<header class="toolbar">
|
||||||
<div class="file-actions">
|
<div class="file-actions">
|
||||||
<button type="button" onclick={() => videoInput?.click()}>Open Video</button>
|
<button type="button" onclick={handleOpenVideo}>Open Video</button>
|
||||||
<button type="button" onclick={() => projectInput?.click()}>Open Project</button>
|
<button type="button" onclick={handleOpenProject}>Open Project</button>
|
||||||
<button type="button" onclick={() => downloadProject()}>Save Project</button>
|
<button type="button" onclick={() => void downloadProject()}>Save Project</button>
|
||||||
<button type="button" onclick={handleExportAudio}>Export Audio</button>
|
<button type="button" onclick={handleExportAudio}>Export Audio</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
@@ -230,8 +228,6 @@
|
|||||||
<span class="status">{statusMessage}</span>
|
<span class="status">{statusMessage}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<input bind:this={videoInput} type="file" accept="video/mp4,video/*" hidden onchange={handleVideoFile} />
|
|
||||||
<input bind:this={projectInput} type="file" accept="application/json,.json" hidden onchange={handleProjectFile} />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="player-section">
|
<section class="player-section">
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as FileService from '../../../bindings/sfxkeeb/fileservice';
|
||||||
|
|
||||||
|
export type VideoOpenResult = {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function openVideo(): Promise<VideoOpenResult | null> {
|
||||||
|
const result = await FileService.OpenVideo();
|
||||||
|
if (!result.url) return null;
|
||||||
|
return { url: result.url, name: result.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProject(): Promise<string | null> {
|
||||||
|
const text = await FileService.OpenProject();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProject(content: string, defaultName = 'project.json'): Promise<boolean> {
|
||||||
|
await FileService.SaveProject(content, defaultName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]!);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAudio(blob: Blob, defaultName = 'keyboard_track.wav'): Promise<boolean> {
|
||||||
|
const dataBase64 = await blobToBase64(blob);
|
||||||
|
await FileService.SaveAudio(dataBase64, defaultName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { renderKeyboardTrack } from './audio/export';
|
import { renderKeyboardTrack } from './audio/export';
|
||||||
import type { SampleCache } from './audio/sampleCache';
|
import type { SampleCache } from './audio/sampleCache';
|
||||||
|
import { saveAudio, saveProject } from './platform/files';
|
||||||
import type { LegacySwitchType, Marker, Project, SwitchType } from './types';
|
import type { LegacySwitchType, Marker, Project, SwitchType } from './types';
|
||||||
import { app, setActiveSwitch, setMarkers, setSelectedIds } from './store.svelte';
|
import { app, setActiveSwitch, setMarkers, setSelectedIds } from './store.svelte';
|
||||||
|
|
||||||
@@ -32,15 +33,10 @@ export function buildProject(): Project {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadProject(filename = 'project.json') {
|
export async function downloadProject(filename = 'project.json') {
|
||||||
const project = buildProject();
|
const project = buildProject();
|
||||||
const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' });
|
const content = JSON.stringify(project, null, 2);
|
||||||
const url = URL.createObjectURL(blob);
|
await saveProject(content, filename);
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadProject(data: Project & { version?: number; switch?: string }) {
|
export function loadProject(data: Project & { version?: number; switch?: string }) {
|
||||||
@@ -65,10 +61,5 @@ export async function exportAudio(duration: number, cache?: SampleCache) {
|
|||||||
cache,
|
cache,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
await saveAudio(blob, 'keyboard_track.wav');
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = 'keyboard_track.wav';
|
|
||||||
anchor.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
import { defineConfig, type Connect, type Plugin } from 'vite'
|
import { defineConfig, type Connect, type Plugin } from 'vite'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import wails from '@wailsio/runtime/plugins/vite'
|
||||||
|
|
||||||
const repoRoot = resolve(fileURLToPath(import.meta.url), '../..')
|
const repoRoot = resolve(fileURLToPath(import.meta.url), '../..')
|
||||||
const assetsDir = resolve(repoRoot, 'assets')
|
const assetsDir = resolve(repoRoot, 'assets')
|
||||||
@@ -49,8 +50,11 @@ function serveRepoAssets(): Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte(), serveRepoAssets()],
|
plugins: [svelte(), wails('./bindings'), serveRepoAssets()],
|
||||||
server: {
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: Number(process.env.WAILS_VITE_PORT) || 5173,
|
||||||
|
strictPort: true,
|
||||||
fs: {
|
fs: {
|
||||||
allow: [repoRoot],
|
allow: [repoRoot],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user