collection list, move, delete
This commit is contained in:
@ -1,13 +1,34 @@
|
||||
[
|
||||
{
|
||||
"Test": [],
|
||||
"Uncategorized": [
|
||||
"name": "Uncategorized",
|
||||
"id": 0,
|
||||
"clips": []
|
||||
},
|
||||
{
|
||||
"endTime": 20.085836909871187,
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260214_114317.wav",
|
||||
"name": "Farts",
|
||||
"name": "Test",
|
||||
"id": 1,
|
||||
"clips": []
|
||||
},
|
||||
{
|
||||
"name": "New",
|
||||
"id": 2,
|
||||
"clips": [
|
||||
{
|
||||
"endTime": 30,
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260220_193822.wav",
|
||||
"name": "Pee pee poo poo",
|
||||
"playbackType": "playStop",
|
||||
"startTime": 17.124463519313306,
|
||||
"volume": 0.8
|
||||
"startTime": 27.756510985786615,
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"endTime": 28.597210828548004,
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260220_200442.wav",
|
||||
"name": "Clip 20260220_200442",
|
||||
"playbackType": "playStop",
|
||||
"startTime": 26.1853978671042,
|
||||
"volume": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -104,10 +104,7 @@ class AudioRecorder:
|
||||
"volume": 1.0,
|
||||
}
|
||||
|
||||
meta.add_clip_to_collection("Uncategorized",
|
||||
{
|
||||
clip_metadata
|
||||
})
|
||||
meta.add_clip_to_collection("Uncategorized", clip_metadata )
|
||||
|
||||
|
||||
return clip_metadata
|
||||
|
||||
@ -17,71 +17,82 @@ class MetaDataManager:
|
||||
self.collections = json.load(f)
|
||||
else:
|
||||
self.collections = {}
|
||||
if(collections := self.collections.get("Uncategorized")) is None:
|
||||
self.collections["Uncategorized"] = []
|
||||
if(collections := next((c for c in self.collections if c.get("name") == "Uncategorized"), None)) is None:
|
||||
self.collections.append({"name": "Uncategorized", "id": 0, "clips": []})
|
||||
self.save_metadata()
|
||||
|
||||
def create_collection(self, name):
|
||||
if name in self.collections:
|
||||
if any(c.get("name") == name for c in self.collections):
|
||||
raise ValueError(f"Collection '{name}' already exists.")
|
||||
self.collections[name] = []
|
||||
new_id = max((c.get("id", 0) for c in self.collections), default=0) + 1
|
||||
self.collections.append({"name": name, "id": new_id, "clips": []})
|
||||
self.save_metadata()
|
||||
|
||||
def delete_collection(self, name):
|
||||
if name not in self.collections:
|
||||
collection = next((c for c in self.collections if c.get("name") == name), None)
|
||||
if collection is None:
|
||||
raise ValueError(f"Collection '{name}' does not exist.")
|
||||
del self.collections[name]
|
||||
self.collections.remove(collection)
|
||||
self.save_metadata()
|
||||
|
||||
def add_clip_to_collection(self, collection_name, clip_metadata):
|
||||
if collection_name not in self.collections:
|
||||
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
|
||||
if collection is None:
|
||||
raise ValueError(f"Collection '{collection_name}' does not exist.")
|
||||
self.collections[collection_name].append(clip_metadata)
|
||||
collection["clips"].append(clip_metadata)
|
||||
self.save_metadata()
|
||||
|
||||
def remove_clip_from_collection(self, collection_name, clip_metadata):
|
||||
if collection_name not in self.collections:
|
||||
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
|
||||
if collection is None:
|
||||
raise ValueError(f"Collection '{collection_name}' does not exist.")
|
||||
# Remove all clips with the same file name as clip_metadata["file_name"]
|
||||
in_list = any(clip.get("filename") == clip_metadata.get("filename") for clip in self.collections[collection_name])
|
||||
in_list = any(clip.get("filename") == clip_metadata.get("filename") for clip in collection["clips"])
|
||||
if not in_list:
|
||||
raise ValueError(f"Clip with filename '{clip_metadata.get('filename')}' not found in collection '{collection_name}'.")
|
||||
|
||||
self.collections[collection_name] = [
|
||||
clip for clip in self.collections[collection_name]
|
||||
collection["clips"] = [
|
||||
clip for clip in collection["clips"]
|
||||
if clip.get("filename") != clip_metadata.get("filename")
|
||||
]
|
||||
self.save_metadata()
|
||||
|
||||
def move_clip_to_collection(self, source_collection, target_collection, clip_metadata):
|
||||
self.remove_clip_from_collection(source_collection, clip_metadata)
|
||||
self.add_clip_to_collection(target_collection, clip_metadata)
|
||||
|
||||
def edit_clip_in_collection(self, collection_name, new_clip_metadata):
|
||||
if collection_name not in self.collections:
|
||||
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
|
||||
if collection is None:
|
||||
raise ValueError(f"Collection '{collection_name}' does not exist.")
|
||||
# Find the index of the clip with the same file name as old_clip_metadata["file_name"]
|
||||
index = next((i for i, clip in enumerate(self.collections[collection_name]) if clip.get("filename") == new_clip_metadata.get("filename")), None)
|
||||
index = next((i for i, clip in enumerate(collection["clips"]) if clip.get("filename") == new_clip_metadata.get("filename")), None)
|
||||
if index is None:
|
||||
raise ValueError(f"Clip with filename '{new_clip_metadata.get('filename')}' not found in collection '{collection_name}'.")
|
||||
|
||||
self.collections[collection_name][index] = new_clip_metadata
|
||||
collection["clips"][index] = new_clip_metadata
|
||||
self.save_metadata()
|
||||
|
||||
def get_collections(self):
|
||||
return list(self.collections.keys())
|
||||
return list(map(lambda c: {"name": c.get("name"), "id": c.get("id")}, self.collections))
|
||||
|
||||
def get_clips_in_collection(self, collection_name):
|
||||
if collection_name not in self.collections:
|
||||
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
|
||||
if collection is None:
|
||||
raise ValueError(f"Collection '{collection_name}' does not exist.")
|
||||
return self.collections[collection_name]
|
||||
return collection["clips"]
|
||||
|
||||
def reorder_clips_in_collection(self, collection_name, new_order):
|
||||
if collection_name not in self.collections:
|
||||
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
|
||||
if collection is None:
|
||||
raise ValueError(f"Collection '{collection_name}' does not exist.")
|
||||
existing_filenames = {clip.get("filename") for clip in self.collections[collection_name]}
|
||||
existing_filenames = {clip.get("filename") for clip in collection["clips"]}
|
||||
new_filenames = {clip.get("filename") for clip in new_order}
|
||||
|
||||
if not new_filenames.issubset(existing_filenames):
|
||||
raise ValueError("New order contains clips that do not exist in the collection.")
|
||||
|
||||
self.collections[collection_name] = new_order
|
||||
collection["clips"] = new_order
|
||||
self.save_metadata()
|
||||
|
||||
def save_metadata(self):
|
||||
|
||||
Binary file not shown.
@ -73,6 +73,19 @@ def remove_clip_from_collection():
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
@metadata_bp.route('/meta/collection/clips/move', methods=['POST'])
|
||||
def move_clip_to_collection():
|
||||
meta_manager = MetaDataManager()
|
||||
sourceCollection = request.json.get('sourceCollection')
|
||||
targetCollection = request.json.get('targetCollection')
|
||||
clip_metadata = request.json.get('clip')
|
||||
try:
|
||||
meta_manager.move_clip_to_collection(sourceCollection, targetCollection, clip_metadata)
|
||||
collections = meta_manager.collections
|
||||
return jsonify({'status': 'success', 'collections': collections})
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
@metadata_bp.route('/meta/collection/clips/edit', methods=['POST'])
|
||||
def edit_clip_in_collection():
|
||||
meta_manager = MetaDataManager()
|
||||
|
||||
@ -14,6 +14,8 @@ module.exports = {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'react/require-default-props': 'off',
|
||||
'react/jsx-no-bind': 'off',
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
parserOptions: {
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Audio Clip Trimmer</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="titlebar"></div>
|
||||
<div class="app-container">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<h3>Collections</h3>
|
||||
<div id="collections-list"></div>
|
||||
<button id="add-collection-btn" class="add-collection-btn">
|
||||
+ New Collection
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div id="nav-buttons">
|
||||
<button id="settings-btn" class="nav-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="restart-btn" class="nav-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<div class="audio-trimmers-section">
|
||||
<div id="audio-trimmers-list" class="audio-trimmers-list">
|
||||
<!-- Audio trimmers will be dynamically added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal">×</span>
|
||||
<h2>Settings</h2>
|
||||
<div class="settings-group">
|
||||
<label for="recording-length">Recording Length (seconds):</label>
|
||||
<input type="number" id="recording-length" min="1" max="300" />
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label for="osc-port">OSC port:</label>
|
||||
<input type="number" id="osc-port" min="5000" max="6000" />
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label for="output-folder">Output Folder:</label>
|
||||
<input type="text" id="output-folder" readonly />
|
||||
<button id="select-output-folder">Browse</button>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label for="input-device">Input Device:</label>
|
||||
<select id="input-device"></select>
|
||||
</div>
|
||||
<button id="save-settings">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="node_modules/wavesurfer.js/dist/wavesurfer.min.js"></script>
|
||||
<script src="node_modules/wavesurfer.js/dist/plugin/wavesurfer.regions.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,483 +0,0 @@
|
||||
const { app, BrowserWindow, ipcMain, Tray, Menu, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const spawn = require('child_process').spawn;
|
||||
require('electron-reload')(__dirname);
|
||||
const fs = require('fs').promises;
|
||||
const chokidar = require('chokidar');
|
||||
const wavefile = require('wavefile');
|
||||
const MetadataManager = require('./metatadata');
|
||||
|
||||
const { webContents } = require('electron');
|
||||
|
||||
// import { app, BrowserWindow, ipcMain, Tray, Menu, dialog } from "electron";
|
||||
// import path from "path";
|
||||
// import os from "os";
|
||||
// import spawn from 'child_process';
|
||||
// import fs from "fs";
|
||||
// import chokidar from "chokidar";
|
||||
// import wavefile from "wavefile";
|
||||
// import MetadataManager from "./metatadata.cjs";
|
||||
// import { webContents } from "electron";
|
||||
|
||||
let mainWindow;
|
||||
let tray;
|
||||
let audioServiceProcess;
|
||||
|
||||
const metadataPath = path.join(app.getPath('userData'), 'audio_metadata.json');
|
||||
const metadataManager = new MetadataManager(metadataPath);
|
||||
|
||||
async function createPythonService() {
|
||||
const pythonPath =
|
||||
process.platform === 'win32'
|
||||
? path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'audio-service',
|
||||
'venv',
|
||||
'Scripts',
|
||||
'python.exe',
|
||||
)
|
||||
: path.join(__dirname, '..', 'audio-service', 'venv', 'bin', 'python');
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'audio-service',
|
||||
'src',
|
||||
'main.py',
|
||||
);
|
||||
|
||||
// Load settings to pass as arguments
|
||||
const settings = await loadSettings(); // Assuming you have a method to load settings
|
||||
|
||||
const args = [
|
||||
scriptPath,
|
||||
'--recording-length',
|
||||
settings.recordingLength.toString(),
|
||||
'--save-path',
|
||||
path.join(settings.outputFolder, 'original'),
|
||||
'--osc-port',
|
||||
settings.oscPort.toString(), // Or make this configurable
|
||||
];
|
||||
|
||||
// Add input device if specified
|
||||
if (settings.inputDevice) {
|
||||
const devices = await listAudioDevices();
|
||||
args.push(
|
||||
'--input-device',
|
||||
devices.find((device) => device.id === settings.inputDevice)?.name,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(args);
|
||||
|
||||
audioServiceProcess = spawn(pythonPath, args, {
|
||||
detached: false,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
audioServiceProcess.stdout.on('data', (data) => {
|
||||
console.log(`Audio Service: ${data}`);
|
||||
});
|
||||
|
||||
audioServiceProcess.stderr.on('data', (data) => {
|
||||
console.error(`Audio Service Error: ${data}`);
|
||||
});
|
||||
|
||||
audioServiceProcess.on('close', (code) => {
|
||||
console.log(`Audio Service process exited with code ${code}`);
|
||||
audioServiceProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
tray = new Tray(path.join(__dirname, 'assets', 'tray-icon.png')); // You'll need to create this icon
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
// Properly terminate the Python service
|
||||
|
||||
stopService();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setToolTip('Audio Trimmer');
|
||||
tray.setContextMenu(contextMenu);
|
||||
}
|
||||
|
||||
async function checkNewWavFile(filePath) {
|
||||
// Only process .wav files
|
||||
if (path.extname(filePath).toLowerCase() === '.wav') {
|
||||
try {
|
||||
await metadataManager.addUntrimmedFile(filePath);
|
||||
|
||||
// Notify renderer if window is ready
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('new-untrimmed-file', filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding untrimmed file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopService() {
|
||||
if (audioServiceProcess) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
|
||||
} else {
|
||||
audioServiceProcess.kill('SIGTERM');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error killing audio service:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restartService() {
|
||||
// Properly terminate the Python service
|
||||
stopService();
|
||||
//delay for 2 seconds
|
||||
setTimeout(createPythonService, 4000);
|
||||
//createPythonService();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
const settingsData = await fs.readFile(settingsPath, 'utf8');
|
||||
return JSON.parse(settingsData);
|
||||
} catch (error) {
|
||||
// If no settings file exists, return default settings
|
||||
return {
|
||||
recordingLength: 30,
|
||||
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
|
||||
inputDevice: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function listAudioDevices() {
|
||||
try {
|
||||
// Use a webContents to access navigator.mediaDevices
|
||||
|
||||
const contents = webContents.getAllWebContents()[0];
|
||||
|
||||
const devices = await contents.executeJavaScript(`
|
||||
navigator.mediaDevices.enumerateDevices()
|
||||
.then(devices => devices.filter(device => device.kind === 'audioinput'))
|
||||
.then(audioDevices => audioDevices.map(device => ({
|
||||
id: device.deviceId,
|
||||
name: device.label || 'Unknown Microphone'
|
||||
})))
|
||||
`);
|
||||
|
||||
return devices;
|
||||
} catch (error) {
|
||||
console.error('Error getting input devices:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async function createWindow() {
|
||||
// Initialize metadata
|
||||
await metadataManager.initialize();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hidden',
|
||||
frame: false,
|
||||
|
||||
// titleBarOverlay: {
|
||||
// color: '#1e1e1e',
|
||||
// symbolColor: '#ffffff',
|
||||
// height: 30
|
||||
// },
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
// Add these to help with graphics issues
|
||||
},
|
||||
// These additional options can help with graphics rendering
|
||||
backgroundColor: '#1e1e1e',
|
||||
...(process.platform !== 'darwin'
|
||||
? {
|
||||
titleBarOverlay: {
|
||||
color: '#262626',
|
||||
symbolColor: '#ffffff',
|
||||
height: 30,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
mainWindow.loadFile('src/index.html');
|
||||
|
||||
// Create Python ser
|
||||
const settings = await loadSettings(); // Assuming you have a method to load settings
|
||||
const recordingsPath = path.join(settings.outputFolder, 'original');
|
||||
// Ensure recordings directory exists
|
||||
try {
|
||||
await fs.mkdir(recordingsPath, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Error creating recordings directory:', error);
|
||||
}
|
||||
|
||||
// Watch for new WAV files
|
||||
const watcher = chokidar.watch(recordingsPath, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
persistent: true,
|
||||
depth: 0,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 2000,
|
||||
pollInterval: 100,
|
||||
},
|
||||
});
|
||||
|
||||
fs.readdir(recordingsPath).then((files) => {
|
||||
files.forEach((file) => {
|
||||
checkNewWavFile(path.join(recordingsPath, file));
|
||||
});
|
||||
});
|
||||
|
||||
watcher.on('add', async (filePath) => {
|
||||
await checkNewWavFile(filePath);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-collections', () => {
|
||||
return metadataManager.getCollections();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-collection-files', (event, collectionPath) => {
|
||||
return metadataManager.getFilesInCollection(collectionPath);
|
||||
});
|
||||
|
||||
ipcMain.handle('add-untrimmed-file', (event, filePath) => {
|
||||
return metadataManager.addUntrimmedFile(filePath);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'save-trimmed-file',
|
||||
(event, fileName, previousPath, savePath, trimStart, trimEnd, title) => {
|
||||
return metadataManager.saveTrimmedFile(
|
||||
fileName,
|
||||
previousPath,
|
||||
savePath,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
title,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('restart', (event) => {
|
||||
restartService();
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-old-file', (event, outputFolder, section, title) => {
|
||||
if (section === 'untrimmed') return;
|
||||
const collectionPath = path.join(outputFolder, section);
|
||||
const outputFilePath = path.join(collectionPath, `${title}.wav`);
|
||||
fs.unlink(outputFilePath);
|
||||
});
|
||||
ipcMain.handle(
|
||||
'save-trimmed-audio',
|
||||
async (
|
||||
event,
|
||||
{
|
||||
originalFilePath,
|
||||
outputFolder,
|
||||
collectionName,
|
||||
title,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
// Ensure the collection folder exists
|
||||
const collectionPath = path.join(outputFolder, collectionName);
|
||||
await fs.mkdir(collectionPath, { recursive: true });
|
||||
|
||||
// Generate output file path
|
||||
const outputFilePath = path.join(collectionPath, `${title}.wav`);
|
||||
|
||||
// Read the original WAV file
|
||||
const originalWaveFile = new wavefile.WaveFile(
|
||||
await fs.readFile(originalFilePath),
|
||||
);
|
||||
|
||||
// Calculate trim points in samples
|
||||
const sampleRate = originalWaveFile.fmt.sampleRate;
|
||||
const startSample = Math.floor(trimStart * sampleRate);
|
||||
const endSample = Math.floor(trimEnd * sampleRate);
|
||||
|
||||
// Extract trimmed audio samples
|
||||
const originalSamples = originalWaveFile.getSamples(false);
|
||||
const trimmedSamples = [
|
||||
originalSamples[0].slice(startSample, endSample),
|
||||
originalSamples[1].slice(startSample, endSample),
|
||||
];
|
||||
|
||||
// Normalize samples if they are Int16 or Int32
|
||||
let normalizedSamples;
|
||||
const bitDepth = originalWaveFile.fmt.bitsPerSample;
|
||||
|
||||
// if (bitDepth === 16) {
|
||||
// // For 16-bit audio, convert to Float32
|
||||
// normalizedSamples = [new Float32Array(trimmedSamples[0].length),new Float32Array(trimmedSamples[0].length)];
|
||||
// for (let i = 0; i < trimmedSamples[0].length; i++) {
|
||||
// normalizedSamples[0][i] = trimmedSamples[0][i] / 32768.0;
|
||||
// normalizedSamples[1][i] = trimmedSamples[1][i] / 32768.0;
|
||||
// }
|
||||
// } else if (bitDepth === 32) {
|
||||
// // For 32-bit float audio, just convert to Float32
|
||||
// normalizedSamples = [new Float32Array(trimmedSamples[0]),new Float32Array(trimmedSamples[1])];
|
||||
// } else {
|
||||
// throw new Error(`Unsupported bit depth: ${bitDepth}`);
|
||||
// }
|
||||
|
||||
// Create a new WaveFile with normalized samples
|
||||
const trimmedWaveFile = new wavefile.WaveFile();
|
||||
trimmedWaveFile.fromScratch(
|
||||
originalWaveFile.fmt.numChannels,
|
||||
sampleRate,
|
||||
bitDepth, // Always use 32-bit float
|
||||
trimmedSamples,
|
||||
);
|
||||
|
||||
// Write the trimmed WAV file
|
||||
await fs.writeFile(outputFilePath, trimmedWaveFile.toBuffer());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: outputFilePath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error saving trimmed audio:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle('delete-file', async (event, filePath) => {
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
return metadataManager.deletefile(filePath, settings.outputFolder);
|
||||
} catch (error) {
|
||||
console.error('Error Deleting file:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('add-new-collection', (event, collectionName) => {
|
||||
try {
|
||||
return metadataManager.addNewCollection(collectionName);
|
||||
} catch (error) {
|
||||
console.error('Error adding collection:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle('get-trim-info', (event, collectionName, filePath) => {
|
||||
return metadataManager.getTrimInfo(collectionName, filePath);
|
||||
});
|
||||
ipcMain.handle(
|
||||
'set-trim-info',
|
||||
(event, collectionName, filePath, trim_info) => {
|
||||
return metadataManager.setTrimInfo(collectionName, filePath, trim_info);
|
||||
},
|
||||
);
|
||||
|
||||
// Add these IPC handlers
|
||||
ipcMain.handle('select-output-folder', async (event) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
return result.filePaths[0] || '';
|
||||
});
|
||||
|
||||
ipcMain.handle('get-default-settings', () => {
|
||||
return {
|
||||
recordingLength: 30,
|
||||
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
|
||||
inputDevice: null,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('save-settings', async (event, settings) => {
|
||||
try {
|
||||
// Ensure output folder exists
|
||||
await fs.mkdir(settings.outputFolder, { recursive: true });
|
||||
|
||||
// Save settings to a file
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
restartService();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('load-settings', async () => {
|
||||
return loadSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-input-devices', async () => {
|
||||
return await listAudioDevices();
|
||||
});
|
||||
|
||||
// Minimize to tray instead of closing
|
||||
mainWindow.on('close', (event) => {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
// Create system tray
|
||||
createTray();
|
||||
|
||||
// Launch Python audio service
|
||||
createPythonService();
|
||||
}
|
||||
app.disableHardwareAcceleration();
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Do nothing - we handle closing via tray
|
||||
});
|
||||
|
||||
// Ensure Python service is killed when app quits
|
||||
app.on('before-quit', () => {
|
||||
if (audioServiceProcess) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
|
||||
} else {
|
||||
audioServiceProcess.kill('SIGTERM');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error killing audio service:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
@ -1,234 +0,0 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
// import fs from 'fs';
|
||||
// import path from 'path';
|
||||
|
||||
class MetadataManager {
|
||||
constructor(metadataPath) {
|
||||
this.metadataPath = metadataPath;
|
||||
this.metadata = {};
|
||||
//this.initialize();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Create metadata file if it doesn't exist
|
||||
console.log(this.metadataPath);
|
||||
await this.ensureMetadataFileExists();
|
||||
|
||||
// Load existing metadata
|
||||
const rawData = await fs.readFile(this.metadataPath, 'utf8');
|
||||
this.metadata = JSON.parse(rawData);
|
||||
} catch (error) {
|
||||
console.error('Error initializing metadata:', error);
|
||||
this.metadata = {};
|
||||
}
|
||||
}
|
||||
|
||||
async ensureMetadataFileExists() {
|
||||
try {
|
||||
await fs.access(this.metadataPath);
|
||||
} catch (error) {
|
||||
// File doesn't exist, create it with an empty object
|
||||
await fs.writeFile(this.metadataPath, JSON.stringify({
|
||||
collections: {
|
||||
untrimmed: {}
|
||||
}
|
||||
}, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async addUntrimmedFile(filePath) {
|
||||
try {
|
||||
// Read existing metadata
|
||||
const metadata = this.metadata;
|
||||
|
||||
// Check if file is already in untrimmed files
|
||||
const fileName = path.basename(filePath);
|
||||
const existingUntrimmedFiles = Object.keys(metadata.collections.untrimmed) || [];
|
||||
|
||||
// Check if the file is already in trimmed files across all collections
|
||||
const collections = Object.keys(metadata.collections || {});
|
||||
const isAlreadyTrimmed = collections.some(collection => {
|
||||
return (Object.keys(metadata.collections[collection] || {})).some(name => {
|
||||
return fileName === name;
|
||||
});
|
||||
});
|
||||
|
||||
// If already trimmed, don't add to untrimmed files
|
||||
if (isAlreadyTrimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent duplicates
|
||||
if (!existingUntrimmedFiles.includes(filePath)) {
|
||||
const d = new Date()
|
||||
metadata.collections.untrimmed[fileName] = {
|
||||
originalPath:filePath,
|
||||
addedAt:d.toISOString()
|
||||
}
|
||||
// Write updated metadata
|
||||
await this.saveMetadata();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error adding untrimmed file:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async saveTrimmedFile(fileName, previousPath, savePath, trimStart, trimEnd, title) {
|
||||
console.log(title);
|
||||
// Ensure collection exists
|
||||
if (!this.metadata.collections[savePath]) {
|
||||
this.metadata.collections[savePath] = {};
|
||||
}
|
||||
|
||||
// Find the original untrimmed file
|
||||
const original = this.metadata.collections[previousPath][fileName];
|
||||
|
||||
// Add to specified collection
|
||||
this.metadata.collections[savePath][fileName] = {
|
||||
...original,
|
||||
title,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
};
|
||||
|
||||
|
||||
// Remove from untrimmed if it exists
|
||||
if(previousPath !== savePath) {
|
||||
// if(previousPath !== 'untrimmed') {
|
||||
// const prevmeta = this.metadata.collections[previousPath][fileName];
|
||||
// let delete_path = path.concat(previousPath, prevmeta.title + ".wav");
|
||||
// }
|
||||
delete this.metadata.collections[previousPath][fileName];
|
||||
}
|
||||
|
||||
await this.saveMetadata();
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async saveMetadata() {
|
||||
try {
|
||||
await fs.writeFile(
|
||||
this.metadataPath,
|
||||
JSON.stringify(this.metadata, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUntrimmedFiles() {
|
||||
try {
|
||||
// Read the metadata file
|
||||
const metadata = await this.readMetadataFile();
|
||||
|
||||
// Get all collections
|
||||
const collections = Object.keys(metadata.collections || {});
|
||||
|
||||
// Collect all trimmed file names across all collections
|
||||
const trimmedFiles = new Set();
|
||||
collections.forEach(collection => {
|
||||
const collectionTrimmedFiles = metadata.collections[collection]?.trimmedFiles || [];
|
||||
collectionTrimmedFiles.forEach(trimmedFile => {
|
||||
trimmedFiles.add(trimmedFile.originalFileName);
|
||||
});
|
||||
});
|
||||
|
||||
// Filter out untrimmed files that have been trimmed
|
||||
const untrimmedFiles = (metadata.untrimmedFiles || []).filter(file =>
|
||||
!trimmedFiles.has(path.basename(file))
|
||||
);
|
||||
|
||||
return untrimmedFiles;
|
||||
} catch (error) {
|
||||
console.error('Error getting untrimmed files:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async deletefile(filePath, collectionPath) {
|
||||
try {
|
||||
const fileName = path.basename(filePath);
|
||||
for (const collection in this.metadata.collections) {
|
||||
if (this.metadata.collections[collection][fileName]) {
|
||||
let delete_path = this.metadata.collections[collection][fileName].originalPath;
|
||||
fs.unlink(delete_path);
|
||||
if(collection !== 'untrimmed') {
|
||||
delete_path = path.join(collectionPath, collection, this.metadata.collections[collection][fileName].title + ".wav");
|
||||
fs.unlink(delete_path);
|
||||
}
|
||||
delete this.metadata.collections[collection][fileName];
|
||||
this.saveMetadata();
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
getCollections() {
|
||||
return Object.keys(this.metadata.collections);
|
||||
}
|
||||
|
||||
getTrimInfo(collectionName, filePath) {
|
||||
return this.metadata.collections[collectionName][filePath] || {
|
||||
trimStart: 0,
|
||||
trimEnd: 0
|
||||
};
|
||||
}
|
||||
|
||||
setTrimInfo(collectionName, filePath, trimInfo) {
|
||||
this.metadata.collections[collectionName][filePath].trimStart = trimInfo.trimStart;
|
||||
this.metadata.collections[collectionName][filePath].trimEnd = trimInfo.trimEnd;
|
||||
this.saveMetadata();
|
||||
}
|
||||
|
||||
getFilesInCollection(collectionPath) {
|
||||
// if(collectionPath === 'untrimmed') {
|
||||
// return Object.keys(this.metadata.untrimmed).map(fileName => ({
|
||||
// fileName,
|
||||
// ...this.metadata.untrimmed[fileName]
|
||||
// }));
|
||||
// }
|
||||
return Object.keys(this.metadata.collections[collectionPath] || {}).map(fileName => {
|
||||
const fileInfo = this.metadata.collections[collectionPath][fileName];
|
||||
return {
|
||||
fileName,
|
||||
...this.metadata.collections[collectionPath][fileName],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addNewCollection(collectionName) {
|
||||
// Ensure collection name is valid
|
||||
if (!collectionName || collectionName.trim() === '') {
|
||||
throw new Error('Collection name cannot be empty');
|
||||
}
|
||||
|
||||
// Normalize collection name (remove leading/trailing spaces, convert to lowercase)
|
||||
const normalizedName = collectionName.trim().toLowerCase();
|
||||
|
||||
// Check if collection already exists
|
||||
if (this.metadata.collections[normalizedName]) {
|
||||
throw new Error(`Collection '${normalizedName}' already exists`);
|
||||
}
|
||||
|
||||
// Add new collection
|
||||
this.metadata.collections[normalizedName] = {};
|
||||
|
||||
// Save updated metadata
|
||||
await this.saveMetadata();
|
||||
|
||||
return normalizedName;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MetadataManager;
|
||||
@ -1,818 +0,0 @@
|
||||
const { ipcRenderer } = require('electron');
|
||||
// const path = require('path');
|
||||
const WaveSurfer = require('wavesurfer.js');
|
||||
const RegionsPlugin = require('wavesurfer.js/dist/plugin/wavesurfer.regions.js');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Settings Modal Logic
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const settingsBtn = document.getElementById('settings-btn');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const closeModalBtn = document.querySelector('.close-modal');
|
||||
const saveSettingsBtn = document.getElementById('save-settings');
|
||||
const selectOutputFolderBtn = document.getElementById('select-output-folder');
|
||||
const recordingLengthInput = document.getElementById('recording-length');
|
||||
const oscPortInput = document.getElementById('osc-port');
|
||||
const outputFolderInput = document.getElementById('output-folder');
|
||||
const inputDeviceSelect = document.getElementById('input-device');
|
||||
|
||||
// Open settings modal
|
||||
settingsBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
// Request microphone permissions first
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// Load current settings
|
||||
const settings = await ipcRenderer.invoke('load-settings');
|
||||
|
||||
// Populate input devices
|
||||
const devices = await ipcRenderer.invoke('get-input-devices');
|
||||
|
||||
if (devices.length === 0) {
|
||||
inputDeviceSelect.innerHTML = '<option>No microphones found</option>';
|
||||
} else {
|
||||
inputDeviceSelect.innerHTML = devices
|
||||
.map(
|
||||
(device) => `<option value="${device.id}">${device.name}</option>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Set current settings
|
||||
recordingLengthInput.value = settings.recordingLength;
|
||||
outputFolderInput.value = settings.outputFolder;
|
||||
inputDeviceSelect.value = settings.inputDevice;
|
||||
oscPortInput.value = settings.oscPort;
|
||||
|
||||
settingsModal.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Error loading settings or devices:', error);
|
||||
alert('Please grant microphone permissions to list audio devices');
|
||||
}
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await ipcRenderer.invoke('restart');
|
||||
} catch (error) {
|
||||
console.error('Error restarting:', error);
|
||||
alert('Failed to restart Clipper');
|
||||
}
|
||||
});
|
||||
|
||||
// Close settings modal
|
||||
closeModalBtn.addEventListener('click', () => {
|
||||
settingsModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Select output folder
|
||||
selectOutputFolderBtn.addEventListener('click', async () => {
|
||||
const folderPath = await ipcRenderer.invoke('select-output-folder');
|
||||
if (folderPath) {
|
||||
outputFolderInput.value = folderPath;
|
||||
}
|
||||
});
|
||||
|
||||
// Save settings
|
||||
saveSettingsBtn.addEventListener('click', async () => {
|
||||
const settings = {
|
||||
recordingLength: parseInt(recordingLengthInput.value),
|
||||
oscPort: parseInt(oscPortInput.value),
|
||||
outputFolder: outputFolderInput.value,
|
||||
inputDevice: inputDeviceSelect.value,
|
||||
};
|
||||
|
||||
const saved = await ipcRenderer.invoke('save-settings', settings);
|
||||
if (saved) {
|
||||
settingsModal.style.display = 'none';
|
||||
} else {
|
||||
alert('Failed to save settings');
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal if clicked outside
|
||||
window.addEventListener('click', (event) => {
|
||||
if (event.target === settingsModal) {
|
||||
settingsModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
const audioTrimmersList = document.getElementById('audio-trimmers-list');
|
||||
const collectionsList = document.getElementById('collections-list');
|
||||
//const currentSectionTitle = document.getElementById("current-section-title");
|
||||
|
||||
// Global state to persist wavesurfer instances and trimmer states
|
||||
const globalState = {
|
||||
wavesurferInstances: {},
|
||||
trimmerStates: {},
|
||||
currentSection: 'untrimmed',
|
||||
trimmerElements: {},
|
||||
};
|
||||
// Utility function to format time
|
||||
function formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Populate collections list
|
||||
async function populateCollectionsList() {
|
||||
const collections = await ipcRenderer.invoke('get-collections');
|
||||
|
||||
collectionsList.innerHTML = '';
|
||||
|
||||
// Always add Untrimmed section first
|
||||
const untrimmedItem = document.createElement('div');
|
||||
untrimmedItem.classList.add('collection-item');
|
||||
untrimmedItem.textContent = 'Untrimmed';
|
||||
untrimmedItem.dataset.collection = 'untrimmed';
|
||||
|
||||
untrimmedItem.addEventListener('click', () => {
|
||||
loadCollectionFiles('untrimmed');
|
||||
});
|
||||
|
||||
collectionsList.appendChild(untrimmedItem);
|
||||
|
||||
// Add other collections
|
||||
collections.forEach((collection) => {
|
||||
if (collection === 'untrimmed') {
|
||||
return;
|
||||
}
|
||||
const collectionItem = document.createElement('div');
|
||||
collectionItem.classList.add('collection-item');
|
||||
collectionItem.textContent = collection;
|
||||
collectionItem.dataset.collection = collection;
|
||||
|
||||
collectionItem.addEventListener('click', () => {
|
||||
loadCollectionFiles(collection);
|
||||
});
|
||||
|
||||
collectionsList.appendChild(collectionItem);
|
||||
});
|
||||
}
|
||||
|
||||
// Modify loadCollectionFiles function
|
||||
async function loadCollectionFiles(collection) {
|
||||
if (collection !== globalState.currentSection) {
|
||||
//Clear existing trimmers and reset global state
|
||||
Object.keys(globalState.trimmerElements).forEach((filePath) => {
|
||||
const trimmerElement = globalState.trimmerElements[filePath];
|
||||
if (trimmerElement && trimmerElement.parentNode) {
|
||||
trimmerElement.parentNode.removeChild(trimmerElement);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset global state
|
||||
globalState.trimmerElements = {};
|
||||
globalState.wavesurferInstances = {};
|
||||
globalState.trimmerStates = {};
|
||||
}
|
||||
|
||||
// Reset active states
|
||||
document.querySelectorAll('.collection-item').forEach((el) => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
// Set active state only for existing items
|
||||
const activeItem = document.querySelector(
|
||||
`.collection-item[data-collection="${collection}"]`,
|
||||
);
|
||||
|
||||
// Only add active class if the item exists
|
||||
if (activeItem) {
|
||||
activeItem.classList.add('active');
|
||||
}
|
||||
|
||||
// Update section title and global state
|
||||
//currentSectionTitle.textContent = collection;
|
||||
globalState.currentSection = collection;
|
||||
|
||||
// Load files
|
||||
const files = await ipcRenderer.invoke('get-collection-files', collection);
|
||||
|
||||
// Add new trimmers with saved trim information
|
||||
for (const file of files) {
|
||||
const filePath = file.originalPath || file.fileName;
|
||||
|
||||
// If loading a collection, use saved trim information
|
||||
//if (collection !== "untrimmed") {
|
||||
// Store trim information in global state before creating trimmer
|
||||
// globalState.trimmerStates[filePath] = {
|
||||
// trimStart: file.trimStart || 0,
|
||||
// trimEnd: file.trimEnd || 0,
|
||||
// regionStart: file.trimStart || 0,
|
||||
// regionEnd: file.trimEnd || 0,
|
||||
// originalPath: file.originalPath,
|
||||
// };
|
||||
//}
|
||||
|
||||
createAudioTrimmer(filePath, collection);
|
||||
}
|
||||
}
|
||||
// Create audio trimmer for a single file
|
||||
async function createAudioTrimmer(filePath, section) {
|
||||
// Check if trimmer already exists
|
||||
if (globalState.trimmerElements[filePath]) {
|
||||
return globalState.trimmerElements[filePath];
|
||||
}
|
||||
|
||||
const savedTrimInfo = await ipcRenderer.invoke(
|
||||
'get-trim-info',
|
||||
globalState.currentSection,
|
||||
path.basename(filePath),
|
||||
);
|
||||
// Create trimmer container
|
||||
const trimmerContainer = document.createElement('div');
|
||||
trimmerContainer.classList.add('audio-trimmer-item');
|
||||
trimmerContainer.dataset.filepath = filePath;
|
||||
|
||||
// Create header with title and controls
|
||||
const trimmerHeader = document.createElement('div');
|
||||
trimmerHeader.classList.add('audio-trimmer-header');
|
||||
|
||||
// Title container
|
||||
const titleContainer = document.createElement('div');
|
||||
titleContainer.classList.add('audio-trimmer-title-container');
|
||||
|
||||
if (savedTrimInfo.title) {
|
||||
// Title
|
||||
const title = document.createElement('div');
|
||||
title.classList.add('audio-trimmer-title');
|
||||
title.textContent = savedTrimInfo.title;
|
||||
titleContainer.appendChild(title);
|
||||
|
||||
// Filename
|
||||
const fileName = document.createElement('div');
|
||||
fileName.classList.add('audio-trimmer-filename');
|
||||
fileName.textContent = path.basename(filePath);
|
||||
titleContainer.appendChild(fileName);
|
||||
} else {
|
||||
// Title (using filename if no custom title)
|
||||
const title = document.createElement('div');
|
||||
title.classList.add('audio-trimmer-title');
|
||||
title.textContent = path.basename(filePath);
|
||||
titleContainer.appendChild(title);
|
||||
|
||||
// Filename
|
||||
const fileName = document.createElement('div');
|
||||
fileName.classList.add('audio-trimmer-filename');
|
||||
fileName.textContent = 'hidden';
|
||||
fileName.style.opacity = 0;
|
||||
titleContainer.appendChild(fileName);
|
||||
}
|
||||
|
||||
// Controls container
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.classList.add('audio-trimmer-controls');
|
||||
|
||||
// Play/Pause and Save buttons
|
||||
const playPauseBtn = document.createElement('button');
|
||||
playPauseBtn.classList.add('play-pause-btn');
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const saveTrimButton = document.createElement('button');
|
||||
saveTrimButton.classList.add('save-trim');
|
||||
saveTrimButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const deletebutton = document.createElement('button');
|
||||
deletebutton.classList.add('play-pause-btn');
|
||||
deletebutton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
controlsContainer.appendChild(playPauseBtn);
|
||||
controlsContainer.appendChild(saveTrimButton);
|
||||
controlsContainer.appendChild(deletebutton);
|
||||
|
||||
// Assemble header
|
||||
trimmerHeader.appendChild(titleContainer);
|
||||
trimmerHeader.appendChild(controlsContainer);
|
||||
trimmerContainer.appendChild(trimmerHeader);
|
||||
|
||||
// Waveform container
|
||||
const waveformContainer = document.createElement('div');
|
||||
waveformContainer.classList.add('waveform-container');
|
||||
const waveformId = `waveform-${path.basename(
|
||||
filePath,
|
||||
path.extname(filePath),
|
||||
)}`;
|
||||
waveformContainer.innerHTML = `
|
||||
<div id="${waveformId}" class="waveform"></div>
|
||||
`;
|
||||
trimmerContainer.appendChild(waveformContainer);
|
||||
|
||||
// Time displays
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.classList.add('trim-info');
|
||||
timeInfo.innerHTML = `
|
||||
<div class="trim-time">
|
||||
<span>Start: </span>
|
||||
<span class="trim-start-time">0:00</span>
|
||||
</div>
|
||||
<div class="trim-time">
|
||||
<span>End: </span>
|
||||
<span class="trim-end-time">0:00</span>
|
||||
</div>
|
||||
`;
|
||||
// const zoomContainer = document.createElement('div');
|
||||
// zoomContainer.className = 'zoom-controls';
|
||||
// zoomContainer.innerHTML = `
|
||||
// <button class="zoom-in">+</button>
|
||||
// <button class="zoom-out">-</button>
|
||||
// <input type="range" min="1" max="200" value="100" class="zoom-slider">
|
||||
// `;
|
||||
// timeInfo.appendChild(zoomContainer);
|
||||
|
||||
// const zoomInBtn = zoomContainer.querySelector('.zoom-in');
|
||||
// const zoomOutBtn = zoomContainer.querySelector('.zoom-out');
|
||||
// const zoomSlider = zoomContainer.querySelector('.zoom-slider');
|
||||
|
||||
// // Zoom functionality
|
||||
// const updateZoom = (zoomLevel) => {
|
||||
// // Get the current scroll position and width
|
||||
// const scrollContainer = wavesurfer.container.querySelector('wave');
|
||||
// const currentScroll = scrollContainer.scrollLeft;
|
||||
// const containerWidth = scrollContainer.clientWidth;
|
||||
|
||||
// // Calculate the center point of the current view
|
||||
// //const centerTime = wavesurfer.getCurrentTime();
|
||||
|
||||
// // Apply zoom
|
||||
// wavesurfer.zoom(zoomLevel);
|
||||
|
||||
// // Recalculate scroll to keep the center point in view
|
||||
// const newDuration = wavesurfer.getDuration();
|
||||
// const pixelsPerSecond = wavesurfer.drawer.width / newDuration;
|
||||
// const centerPixel = centerTime * pixelsPerSecond;
|
||||
|
||||
// // Adjust scroll to keep the center point in the same relative position
|
||||
// const newScrollLeft = centerPixel - (containerWidth / 2);
|
||||
// scrollContainer.scrollLeft = Math.max(0, newScrollLeft);
|
||||
// console.log(currentScroll, newScrollLeft);
|
||||
// };
|
||||
|
||||
// zoomInBtn.addEventListener('click', () => {
|
||||
// const currentZoom = parseInt(zoomSlider.value);
|
||||
// zoomSlider.value = Math.min(currentZoom + 20, 200);
|
||||
// updateZoom(zoomSlider.value);
|
||||
// });
|
||||
|
||||
// zoomOutBtn.addEventListener('click', () => {
|
||||
// const currentZoom = parseInt(zoomSlider.value);
|
||||
// zoomSlider.value = Math.max(currentZoom - 20, 1);
|
||||
// updateZoom(zoomSlider.value);
|
||||
// });
|
||||
|
||||
// zoomSlider.addEventListener('input', (e) => {
|
||||
// updateZoom(e.target.value);
|
||||
// });
|
||||
|
||||
trimmerContainer.appendChild(timeInfo);
|
||||
|
||||
// Add to list and global state
|
||||
audioTrimmersList.appendChild(trimmerContainer);
|
||||
globalState.trimmerElements[filePath] = trimmerContainer;
|
||||
|
||||
// Determine the file to load (original or current)
|
||||
const fileToLoad =
|
||||
section === 'untrimmed'
|
||||
? filePath
|
||||
: globalState.trimmerStates[filePath]?.originalPath || filePath;
|
||||
|
||||
// Setup wavesurfer
|
||||
const wavesurfer = WaveSurfer.create({
|
||||
container: `#${waveformId}`,
|
||||
waveColor: '#ccb1ff',
|
||||
progressColor: '#6e44ba',
|
||||
responsive: true,
|
||||
height: 100,
|
||||
hideScrollbar: true,
|
||||
// barWidth: 2,
|
||||
// barRadius: 3,
|
||||
cursorWidth: 1,
|
||||
backend: 'WebAudio',
|
||||
plugins: [
|
||||
RegionsPlugin.create({
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
dragSelection: {
|
||||
slop: 20,
|
||||
},
|
||||
}),
|
||||
// ZoomPlugin.create({
|
||||
// // the amount of zoom per wheel step, e.g. 0.5 means a 50% magnification per scroll
|
||||
// scale: 0.5,
|
||||
// // Optionally, specify the maximum pixels-per-second factor while zooming
|
||||
// maxZoom: 100,
|
||||
// }),
|
||||
],
|
||||
});
|
||||
|
||||
// Store wavesurfer instance in global state
|
||||
globalState.wavesurferInstances[filePath] = wavesurfer;
|
||||
|
||||
// Use existing trim state or create new one
|
||||
globalState.trimmerStates[filePath] = globalState.trimmerStates[filePath] ||
|
||||
savedTrimInfo || {
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
regionStart: undefined,
|
||||
regionEnd: undefined,
|
||||
originalPath: fileToLoad,
|
||||
};
|
||||
const startTimeDisplay = timeInfo.querySelector('.trim-start-time');
|
||||
const endTimeDisplay = timeInfo.querySelector('.trim-end-time');
|
||||
|
||||
// Load audio file
|
||||
wavesurfer.load(`file://${fileToLoad}`);
|
||||
|
||||
// Setup play/pause button
|
||||
playPauseBtn.onclick = () => {
|
||||
const instanceState = globalState.trimmerStates[filePath];
|
||||
if (wavesurfer.isPlaying()) {
|
||||
wavesurfer.pause();
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
// Always start from the trim start
|
||||
wavesurfer.play(instanceState.trimStart, instanceState.trimEnd);
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
// When audio is ready
|
||||
wavesurfer.on('ready', async () => {
|
||||
const instanceState = globalState.trimmerStates[filePath];
|
||||
|
||||
// Set trim times based on saved state or full duration
|
||||
if (instanceState.trimStart) {
|
||||
// Create initial region covering trim or full duration
|
||||
wavesurfer.clearRegions();
|
||||
const region = wavesurfer.addRegion({
|
||||
start: instanceState.trimStart,
|
||||
end: instanceState.trimEnd,
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
});
|
||||
}
|
||||
instanceState.trimStart = instanceState.trimStart || 0;
|
||||
instanceState.trimEnd = instanceState.trimEnd || wavesurfer.getDuration();
|
||||
|
||||
// Update time displays
|
||||
startTimeDisplay.textContent = formatTime(instanceState.trimStart);
|
||||
endTimeDisplay.textContent = formatTime(instanceState.trimEnd);
|
||||
|
||||
// Store region details
|
||||
instanceState.regionStart = instanceState.trimStart;
|
||||
instanceState.regionEnd = instanceState.trimEnd;
|
||||
|
||||
// Listen for region updates
|
||||
wavesurfer.on('region-update-end', async (updatedRegion) => {
|
||||
// Ensure the region doesn't exceed audio duration
|
||||
instanceState.trimStart = Math.max(0, updatedRegion.start);
|
||||
instanceState.trimEnd = Math.min(
|
||||
wavesurfer.getDuration(),
|
||||
updatedRegion.end,
|
||||
);
|
||||
|
||||
// Update time displays
|
||||
startTimeDisplay.textContent = formatTime(instanceState.trimStart);
|
||||
endTimeDisplay.textContent = formatTime(instanceState.trimEnd);
|
||||
|
||||
// Store updated region details
|
||||
instanceState.regionStart = instanceState.trimStart;
|
||||
instanceState.regionEnd = instanceState.trimEnd;
|
||||
|
||||
globalState.trimmerStates[filePath] = instanceState;
|
||||
|
||||
// Adjust region if it exceeds bounds
|
||||
updatedRegion.update({
|
||||
start: instanceState.trimStart,
|
||||
end: instanceState.trimEnd,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle region creation
|
||||
wavesurfer.on('region-created', (newRegion) => {
|
||||
// Remove all other regions
|
||||
Object.keys(wavesurfer.regions.list).forEach((id) => {
|
||||
if (wavesurfer.regions.list[id] !== newRegion) {
|
||||
wavesurfer.regions.list[id].remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset to trim start when audio finishes
|
||||
wavesurfer.on('finish', () => {
|
||||
wavesurfer.setCurrentTime(instanceState.trimStart);
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
`;
|
||||
});
|
||||
|
||||
// Save trimmed audio functionality
|
||||
saveTrimButton.addEventListener('click', async () => {
|
||||
try {
|
||||
// Get current collections
|
||||
const collections = await ipcRenderer.invoke('get-collections');
|
||||
|
||||
// Create a dialog to select or create a collection
|
||||
const dialogHtml = `
|
||||
<div id="save-collection-dialog"
|
||||
style="
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background-color: #2a2a2a;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
">
|
||||
<div style="">
|
||||
<input type="text" id="new-save-title" placeholder="Title">
|
||||
</div>
|
||||
<select id="existing-collections" style="width: 100%; margin-top: 10px;">
|
||||
${collections
|
||||
.map((col) =>
|
||||
col === 'untrimmed'
|
||||
? ''
|
||||
: `<option value="${col}" ${
|
||||
globalState.currentSection === col ? 'selected' : ''
|
||||
}>${col}</option>`,
|
||||
)
|
||||
.join('')}
|
||||
</select>
|
||||
|
||||
<div style="margin-top: 10px; display: flex; justify-content: space-between;">
|
||||
<button class="play-pause-btn" id="cancel-save-btn" style="width: 48%; ">Cancel</button>
|
||||
<button class="play-pause-btn" id="save-to-collection-btn" style="width: 48%;">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create dialog overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
|
||||
overlay.style.zIndex = '999';
|
||||
overlay.innerHTML = dialogHtml;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const existingCollectionsSelect = overlay.querySelector(
|
||||
'#existing-collections',
|
||||
);
|
||||
|
||||
const newSaveTitleInput = overlay.querySelector('#new-save-title');
|
||||
const createCollectionBtn = overlay.querySelector(
|
||||
'#create-collection-btn',
|
||||
);
|
||||
const saveToCollectionBtn = overlay.querySelector(
|
||||
'#save-to-collection-btn',
|
||||
);
|
||||
const cancelSaveBtn = overlay.querySelector('#cancel-save-btn');
|
||||
|
||||
if (savedTrimInfo.title) {
|
||||
newSaveTitleInput.value = savedTrimInfo.title;
|
||||
}
|
||||
|
||||
// Save to collection
|
||||
saveToCollectionBtn.addEventListener('click', async () => {
|
||||
const newTitle = document
|
||||
.getElementById('new-save-title')
|
||||
.value.trim();
|
||||
const settings = await ipcRenderer.invoke('load-settings');
|
||||
|
||||
const selectedCollection = existingCollectionsSelect.value;
|
||||
|
||||
if (!selectedCollection) {
|
||||
alert('Please select or create a collection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke(
|
||||
'delete-old-file',
|
||||
settings.outputFolder,
|
||||
globalState.currentSection,
|
||||
savedTrimInfo.title,
|
||||
);
|
||||
await ipcRenderer.invoke(
|
||||
'save-trimmed-file',
|
||||
path.basename(filePath),
|
||||
globalState.currentSection,
|
||||
selectedCollection,
|
||||
instanceState.trimStart,
|
||||
instanceState.trimEnd,
|
||||
newTitle,
|
||||
);
|
||||
|
||||
const saveResult = await ipcRenderer.invoke(
|
||||
'save-trimmed-audio',
|
||||
{
|
||||
originalFilePath: filePath,
|
||||
outputFolder: settings.outputFolder,
|
||||
collectionName: selectedCollection,
|
||||
title: newTitle,
|
||||
trimStart: instanceState.trimStart,
|
||||
trimEnd: instanceState.trimEnd,
|
||||
},
|
||||
);
|
||||
|
||||
if (saveResult.success) {
|
||||
// Close save dialog
|
||||
// Remove dialog
|
||||
document.body.removeChild(overlay);
|
||||
trimmerContainer.remove();
|
||||
await loadCollectionFiles(globalState.currentSection);
|
||||
await populateCollectionsList();
|
||||
|
||||
// Optional: Show success message
|
||||
//alert(`Trimmed audio saved to ${saveResult.filePath}`);
|
||||
} else {
|
||||
alert(`Failed to save trimmed audio: ${saveResult.error}`);
|
||||
}
|
||||
|
||||
// Refresh the view
|
||||
} catch (error) {
|
||||
alert('Error saving file: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
cancelSaveBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(overlay);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating save dialog:', error);
|
||||
}
|
||||
});
|
||||
deletebutton.addEventListener('click', async () => {
|
||||
// Create confirmation dialog
|
||||
const confirmDelete = confirm(
|
||||
`Are you sure you want to delete this audio file?\nThis will remove the original file and any trimmed versions.`,
|
||||
);
|
||||
|
||||
if (confirmDelete) {
|
||||
try {
|
||||
// Delete original file
|
||||
await ipcRenderer.invoke('delete-file', filePath);
|
||||
|
||||
// Remove from UI
|
||||
trimmerContainer.remove();
|
||||
|
||||
// Optional: Notify user
|
||||
alert('File deleted successfully');
|
||||
|
||||
// Refresh the current section view
|
||||
await loadCollectionFiles(globalState.currentSection);
|
||||
await populateCollectionsList();
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return trimmerContainer;
|
||||
}
|
||||
|
||||
// Initial load of untrimmed files and collections
|
||||
await loadCollectionFiles('untrimmed');
|
||||
await populateCollectionsList();
|
||||
|
||||
// Listen for new untrimmed files
|
||||
ipcRenderer.on('new-untrimmed-file', async (event, filePath) => {
|
||||
// Refresh the untrimmed section
|
||||
await loadCollectionFiles('untrimmed');
|
||||
await populateCollectionsList();
|
||||
});
|
||||
|
||||
// Periodic refresh
|
||||
setInterval(async () => {
|
||||
await populateCollectionsList();
|
||||
await loadCollectionFiles(globalState.currentSection);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Add collection button handler
|
||||
document
|
||||
.getElementById('add-collection-btn')
|
||||
.addEventListener('click', async () => {
|
||||
try {
|
||||
// Create a dialog to input new collection name
|
||||
const dialogHtml = `
|
||||
<div id="new-collection-dialog" style="
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #2a2a2a;
|
||||
padding: 0px 10px 10px 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
">
|
||||
<h4>Create New Collection</h4>
|
||||
<input
|
||||
type="text"
|
||||
id="new-collection-input"
|
||||
placeholder="Enter collection name"
|
||||
style="width: 100%; align-self: center; padding: 10px; margin-bottom: 10px;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
<button id="create-collection-cancel-btn" class="play-pause-btn" style="width: 48%; ">Cancel</button>
|
||||
<button id="create-collection-confirm-btn" class="play-pause-btn" style="width: 48%; ">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create dialog overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
|
||||
overlay.style.zIndex = '999';
|
||||
overlay.innerHTML = dialogHtml;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const newCollectionInput = overlay.querySelector('#new-collection-input');
|
||||
const createCollectionConfirmBtn = overlay.querySelector(
|
||||
'#create-collection-confirm-btn',
|
||||
);
|
||||
const createCollectionCancelBtn = overlay.querySelector(
|
||||
'#create-collection-cancel-btn',
|
||||
);
|
||||
|
||||
// Create collection when confirm button is clicked
|
||||
createCollectionConfirmBtn.addEventListener('click', async () => {
|
||||
const newCollectionName = newCollectionInput.value.trim();
|
||||
|
||||
if (newCollectionName) {
|
||||
try {
|
||||
await ipcRenderer.invoke('add-new-collection', newCollectionName);
|
||||
|
||||
// Remove dialog
|
||||
document.body.removeChild(overlay);
|
||||
|
||||
// Refresh collections list
|
||||
await populateCollectionsList();
|
||||
} catch (error) {
|
||||
// Show error in the dialog
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.textContent = error.message;
|
||||
errorDiv.style.color = 'red';
|
||||
errorDiv.style.marginTop = '10px';
|
||||
overlay.querySelector('div').appendChild(errorDiv);
|
||||
}
|
||||
} else {
|
||||
// Show error if input is empty
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.textContent = 'Collection name cannot be empty';
|
||||
errorDiv.style.color = 'red';
|
||||
errorDiv.style.marginTop = '10px';
|
||||
overlay.querySelector('div').appendChild(errorDiv);
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button closes the dialog
|
||||
createCollectionCancelBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(overlay);
|
||||
});
|
||||
|
||||
// Focus the input when dialog opens
|
||||
newCollectionInput.focus();
|
||||
} catch (error) {
|
||||
console.error('Error creating new collection dialog:', error);
|
||||
}
|
||||
});
|
||||
@ -1,355 +0,0 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #1e1e1e;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 30px;
|
||||
background: #262626;
|
||||
-webkit-app-region: drag;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: calc(100vh);
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background-color: #1e1e1e;
|
||||
border-right: 1px solid #303030;
|
||||
padding: 5px 20px 20px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sidebar-section h3 {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #393939;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.sidebar-section .add-collection-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background-color: #6e44ba;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.sidebar-section .add-collection-btn:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
.section-item, .collection-item {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.section-item:hover, .collection-item:hover {
|
||||
background-color: #303030;
|
||||
}
|
||||
|
||||
.section-item.active, .collection-item.active {
|
||||
background-color: rgba(110, 68, 186, 0.3);
|
||||
color: #ccb1ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-trimmers-section {
|
||||
background-color: #1e1e1e;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-trimmers-list {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
}
|
||||
|
||||
.audio-trimmers-list::-webkit-scrollbar { /* WebKit */
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.audio-trimmer-item {
|
||||
position: relative;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 0px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.audio-trimmer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.audio-trimmer-title-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-trimmer-title {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.audio-trimmer-filename {
|
||||
color: #888;
|
||||
font-weight: regular;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audio-trimmer-controls {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.trim-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
/* margin-bottom: 20px; */
|
||||
}
|
||||
|
||||
.trim-time {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
.play-pause-btn, .save-trim {
|
||||
background-color: #6e44ba;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.play-pause-btn:hover, .save-trim:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
.play-pause-btn svg, .save-trim svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #383838;
|
||||
border-radius: 5px;
|
||||
border-color:#303030;
|
||||
color: #ffffffd3;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input{
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #383838;
|
||||
border-radius: 5px;
|
||||
border-color:#303030;
|
||||
color: #ffffffd3;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #303030;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: #303030;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
select:active {
|
||||
border-color: #303030;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Settings Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: #2a2a2a;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
width: 500px;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.close-modal {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#save-settings {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #6e44ba;
|
||||
color: #ffffffd3;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#save-settings:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
#select-output-folder {
|
||||
width: 15%;
|
||||
height: 36px;
|
||||
padding: 10px;
|
||||
background-color: #6e44ba;
|
||||
color: #ffffffd3;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#select-output-folder:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
|
||||
#input-device {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
#output-folder {
|
||||
width: 84%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: #ffffffd3;
|
||||
}
|
||||
|
||||
/* Zoom controls styling */
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.zoom-controls button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoom-controls .zoom-slider {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#recording-length, #osc-port {
|
||||
width: 20%;
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { createSlice, configureStore } from '@reduxjs/toolkit';
|
||||
import { ClipMetadata, MetadataState } from './types';
|
||||
|
||||
const initialState: MetadataState = {
|
||||
collections: {},
|
||||
collections: [],
|
||||
};
|
||||
const metadataSlice = createSlice({
|
||||
name: 'metadata',
|
||||
@ -13,32 +13,87 @@ const metadataSlice = createSlice({
|
||||
},
|
||||
setCollections(state, action) {
|
||||
const { collection, newMetadata } = action.payload;
|
||||
state.collections[collection] = newMetadata;
|
||||
const index = state.collections.findIndex(
|
||||
(col) => col.name === collection,
|
||||
);
|
||||
if (index !== -1) {
|
||||
state.collections[index] = newMetadata;
|
||||
}
|
||||
},
|
||||
addCollection(state, action) {
|
||||
const name = action.payload;
|
||||
if (!state.collections.find((col) => col.name === name)) {
|
||||
state.collections.push({ name, id: Date.now(), clips: [] });
|
||||
}
|
||||
},
|
||||
editClip(state, action) {
|
||||
const { collection, clip } = action.payload;
|
||||
const clips = state.collections[collection];
|
||||
const collectionState = state.collections.find(
|
||||
(col) => col.name === collection,
|
||||
);
|
||||
// console.log('Editing clip in collection:', collection, clip);
|
||||
if (clips) {
|
||||
const index = clips.findIndex((c) => c.filename === clip.filename);
|
||||
if (collectionState) {
|
||||
const index = collectionState.clips.findIndex(
|
||||
(c) => c.filename === clip.filename,
|
||||
);
|
||||
if (index !== -1) {
|
||||
clips[index] = clip;
|
||||
collectionState.clips[index] = clip;
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteClip(state, action) {
|
||||
const { collection, clip } = action.payload;
|
||||
const collectionState = state.collections.find(
|
||||
(col) => col.name === collection,
|
||||
);
|
||||
if (collectionState) {
|
||||
collectionState.clips = collectionState.clips.filter(
|
||||
(c) => c.filename !== clip.filename,
|
||||
);
|
||||
}
|
||||
},
|
||||
moveClip(state, action) {
|
||||
const { sourceCollection, targetCollection, clip } = action.payload;
|
||||
const sourceState = state.collections.find(
|
||||
(col) => col.name === sourceCollection,
|
||||
);
|
||||
const targetState = state.collections.find(
|
||||
(col) => col.name === targetCollection,
|
||||
);
|
||||
if (sourceState && targetState) {
|
||||
sourceState.clips = sourceState.clips.filter(
|
||||
(c) => c.filename !== clip.filename,
|
||||
);
|
||||
targetState.clips.push(clip);
|
||||
}
|
||||
},
|
||||
addNewClips(state, action) {
|
||||
const { collections } = action.payload;
|
||||
Object.keys(collections).forEach((collection) => {
|
||||
if (!state.collections[collection]) {
|
||||
state.collections[collection] = [];
|
||||
const collectionState = state.collections.find(
|
||||
(col) => col.name === collection,
|
||||
);
|
||||
if (!collectionState) {
|
||||
state.collections.push({
|
||||
name: collection,
|
||||
id: Date.now(),
|
||||
clips: [],
|
||||
});
|
||||
}
|
||||
const existingFilenames = new Set(
|
||||
state.collections[collection].map((clip) => clip.filename),
|
||||
state.collections
|
||||
.find((col) => col.name === collection)
|
||||
?.clips.map((clip) => clip.filename) || [],
|
||||
);
|
||||
const newClips = collections[collection].filter(
|
||||
(clip: ClipMetadata) => !existingFilenames.has(clip.filename),
|
||||
);
|
||||
state.collections[collection].push(...newClips);
|
||||
// const collectionState = state.collections.find(
|
||||
// (col) => col.name === collection,
|
||||
// );
|
||||
if (collectionState) {
|
||||
collectionState.clips.push(...newClips);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
@ -58,5 +113,6 @@ export type RootState = ReturnType<AppStore['getState']>;
|
||||
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
||||
export type AppDispatch = AppStore['dispatch'];
|
||||
|
||||
export const { setCollections, addNewClips } = metadataSlice.actions;
|
||||
export const { setCollections, addNewClips, addCollection } =
|
||||
metadataSlice.actions;
|
||||
export default metadataSlice.reducer;
|
||||
|
||||
@ -12,6 +12,12 @@ export interface ClipMetadata {
|
||||
playbackType: PlaybackType;
|
||||
}
|
||||
|
||||
export interface MetadataState {
|
||||
collections: Record<string, ClipMetadata[]>;
|
||||
export interface CollectionState {
|
||||
name: string;
|
||||
id: number;
|
||||
clips: ClipMetadata[];
|
||||
}
|
||||
|
||||
export interface MetadataState {
|
||||
collections: CollectionState[];
|
||||
}
|
||||
|
||||
@ -1,42 +1,152 @@
|
||||
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
// import 'tailwindcss/tailwind.css';
|
||||
import icon from '../../assets/icon.svg';
|
||||
import './App.css';
|
||||
import ClipList from './components/ClipList';
|
||||
import { useAppDispatch } from './hooks';
|
||||
import { useAppDispatch, useAppSelector } from './hooks';
|
||||
import { store } from '../redux/main';
|
||||
|
||||
function MainPage() {
|
||||
const [collection, setCollection] = useState<string | null>('Uncategorized');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const collections = useAppSelector((state) =>
|
||||
state.collections.map((col) => col.name),
|
||||
);
|
||||
const [selectedCollection, setSelectedCollection] = useState<string>(
|
||||
collections[0] || 'Uncategorized',
|
||||
);
|
||||
const [newCollectionOpen, setNewCollectionOpen] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5010/meta');
|
||||
const data = await response.json();
|
||||
// console.log('Fetched collections:', data.collections);
|
||||
dispatch({ type: 'metadata/addNewClips', payload: data });
|
||||
dispatch({ type: 'metadata/setAllData', payload: data });
|
||||
} catch (error) {
|
||||
console.error('Error fetching metadata:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetadata();
|
||||
const intervalId = setInterval(fetchMetadata, 5000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [dispatch]);
|
||||
|
||||
// 1. Set up the interval
|
||||
const intervalId = setInterval(async () => {
|
||||
fetchMetadata();
|
||||
}, 5000); // 1000 milliseconds delay
|
||||
useEffect(() => {
|
||||
// Update selected collection if collections change
|
||||
if (collections.length > 0 && !collections.includes(selectedCollection)) {
|
||||
setSelectedCollection(collections[0]);
|
||||
}
|
||||
}, [collections, selectedCollection]);
|
||||
|
||||
// 2. Return a cleanup function to clear the interval when the component unmounts
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
const handleNewCollectionSave = () => {
|
||||
if (
|
||||
newCollectionName.trim() &&
|
||||
!collections.includes(newCollectionName.trim())
|
||||
) {
|
||||
dispatch({
|
||||
type: 'metadata/addCollection',
|
||||
payload: newCollectionName.trim(),
|
||||
});
|
||||
setSelectedCollection(newCollectionName.trim());
|
||||
fetch('http://localhost:5010/meta/collections/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newCollectionName.trim() }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((err) => console.error('Error creating collection:', err));
|
||||
}
|
||||
setNewCollectionOpen(false);
|
||||
setNewCollectionName('');
|
||||
};
|
||||
}, [dispatch]); //
|
||||
return <ClipList collection={collection} />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen min-w-screen bg-midnight text-offwhite relative">
|
||||
{/* Left Nav Bar - sticky */}
|
||||
<Dialog
|
||||
open={newCollectionOpen}
|
||||
onClose={() => setNewCollectionOpen(false)}
|
||||
slotProps={{
|
||||
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Clip Name</DialogTitle>
|
||||
<DialogContent>
|
||||
<input
|
||||
autoFocus
|
||||
className="font-bold text-lg bg-transparent outline-none border-b border-plum focus:border-plumDark text-white mb-1 w-full text-center"
|
||||
type="text"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleNewCollectionSave();
|
||||
}}
|
||||
aria-label="New collection name"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewCollectionOpen(false);
|
||||
setNewCollectionName('');
|
||||
}}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewCollectionSave}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<nav
|
||||
className="w-48 h-screen sticky top-0 left-0 border-r border-neutral-700 bg-midnight flex flex-col p-2"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<div className="p-4 font-bold text-lg">Collections</div>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded text-left px-4 py-2 mb-2 bg-plumDark text-offwhite font-semibold hover:bg-plum"
|
||||
onClick={() => setNewCollectionOpen(true)}
|
||||
>
|
||||
+ Create Collection
|
||||
</button>
|
||||
</li>
|
||||
<ul className="flex-1 overflow-y-auto">
|
||||
{collections.map((col) => (
|
||||
<li key={col}>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full rounded text-left px-4 py-2 mt-2 hover:bg-plumDark ${selectedCollection === col ? 'bg-plum text-offwhite font-semibold' : 'text-offwhite'}`}
|
||||
onClick={() => setSelectedCollection(col)}
|
||||
>
|
||||
{col}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className="absolute top-0 ml-[12rem] w-[calc(100%-12rem)] h-screen overflow-y-auto p-4"
|
||||
// style={{ left: '12rem', width: 'calc(100% - 12rem)' }}
|
||||
>
|
||||
<ClipList collection={selectedCollection} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
|
||||
@ -10,41 +10,42 @@ import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import { useWavesurfer } from '@wavesurfer/react';
|
||||
import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js';
|
||||
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
|
||||
import ZoomPlugin from 'wavesurfer.js/dist/plugins/zoom.esm.js';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import PauseIcon from '@mui/icons-material/Pause';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
|
||||
import { ClipMetadata } from '../../redux/types';
|
||||
import { useAppSelector } from '../hooks';
|
||||
|
||||
export interface AudioTrimmerProps {
|
||||
filename: string;
|
||||
metadata: ClipMetadata;
|
||||
onSave?: (metadata: ClipMetadata) => void;
|
||||
onDelete?: () => void;
|
||||
onDelete?: (metadata: ClipMetadata) => void;
|
||||
onMove?: (newCollection: string, metadata: ClipMetadata) => void;
|
||||
}
|
||||
|
||||
export default function AudioTrimmer({
|
||||
filename,
|
||||
metadata,
|
||||
onSave,
|
||||
onDelete,
|
||||
onMove,
|
||||
}: AudioTrimmerProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: filename });
|
||||
useSortable({ id: metadata.filename });
|
||||
|
||||
const metadata = useAppSelector((state) => {
|
||||
const clip = Object.values(state.collections)
|
||||
.flat()
|
||||
.find((c) => c.filename === filename);
|
||||
return clip ?? ({ filename, name: 'Unknown Clip' } as ClipMetadata);
|
||||
});
|
||||
// Dialog state for editing name
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [nameInput, setNameInput] = useState<string>(metadata.name);
|
||||
const collectionNames = useAppSelector((state) =>
|
||||
state.collections.map((col) => col.name),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNameInput(metadata.name);
|
||||
@ -68,7 +69,7 @@ export default function AudioTrimmer({
|
||||
|
||||
const plugins = useMemo(
|
||||
() => [
|
||||
Regions.create(),
|
||||
RegionsPlugin.create(),
|
||||
ZoomPlugin.create({
|
||||
scale: 0.25,
|
||||
}),
|
||||
@ -282,7 +283,7 @@ export default function AudioTrimmer({
|
||||
>
|
||||
{metadata.name}
|
||||
</span>
|
||||
<text className="text-sm text-neutral-500">{fileBaseName}</text>
|
||||
<span className="text-sm text-neutral-500">{fileBaseName}</span>
|
||||
</div>
|
||||
<Dialog
|
||||
open={editDialogOpen}
|
||||
@ -294,6 +295,7 @@ export default function AudioTrimmer({
|
||||
<DialogTitle>Edit Clip Name</DialogTitle>
|
||||
<DialogContent>
|
||||
<input
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
className="font-bold text-lg bg-transparent outline-none border-b border-plum focus:border-plumDark text-white mb-1 w-full text-center"
|
||||
type="text"
|
||||
@ -302,6 +304,7 @@ export default function AudioTrimmer({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleDialogSave();
|
||||
}}
|
||||
onFocus={(event) => event.target.select()}
|
||||
aria-label="Edit clip name"
|
||||
/>
|
||||
</DialogContent>
|
||||
@ -323,6 +326,38 @@ export default function AudioTrimmer({
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
slotProps={{
|
||||
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete this clip?
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
if (onDelete) onDelete(metadataRef.current);
|
||||
}}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
@ -331,16 +366,36 @@ export default function AudioTrimmer({
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayArrowIcon />}
|
||||
</button>
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
||||
onClick={() => setDropdownOpen((prev) => !prev)}
|
||||
>
|
||||
<ArrowForwardIcon />
|
||||
{dropdownOpen ? <ArrowDownwardIcon /> : <ArrowForwardIcon />}
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute z-10 mt-2 w-40 bg-midnight rounded-md shadow-lg">
|
||||
{collectionNames.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="block w-full text-left px-4 py-2 text-white hover:bg-plumDark"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
if (onMove) onMove(name, metadata);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
||||
onClick={onDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</button>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@ -21,7 +22,8 @@ export interface ClipListProps {
|
||||
|
||||
export default function ClipList({ collection }: ClipListProps) {
|
||||
const metadata = useAppSelector(
|
||||
(state) => state.collections[collection] || [],
|
||||
(state) =>
|
||||
state.collections.find((col) => col.name === collection) || { clips: [] },
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@ -29,14 +31,19 @@ export default function ClipList({ collection }: ClipListProps) {
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
async function handleDragEnd(event) {
|
||||
async function handleDragEnd(event: any) {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = metadata.findIndex(
|
||||
const oldIndex = metadata.clips.findIndex(
|
||||
(item) => item.filename === active.id,
|
||||
);
|
||||
const newIndex = metadata.findIndex((item) => item.filename === over.id);
|
||||
const newMetadata = arrayMove(metadata, oldIndex, newIndex);
|
||||
const newIndex = metadata.clips.findIndex(
|
||||
(item) => item.filename === over.id,
|
||||
);
|
||||
const newMetadata = {
|
||||
...metadata,
|
||||
clips: arrayMove(metadata.clips, oldIndex, newIndex),
|
||||
};
|
||||
console.log('New order:', newMetadata);
|
||||
dispatch({
|
||||
type: 'metadata/setCollections',
|
||||
@ -52,7 +59,7 @@ export default function ClipList({ collection }: ClipListProps) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: collection,
|
||||
clips: newMetadata,
|
||||
clips: newMetadata.clips,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@ -66,6 +73,47 @@ export default function ClipList({ collection }: ClipListProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(meta: ClipMetadata) {
|
||||
dispatch({
|
||||
type: 'metadata/deleteClip',
|
||||
payload: { collection, clip: meta },
|
||||
});
|
||||
fetch('http://localhost:5010/meta/collection/clips/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: collection,
|
||||
clip: meta,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((err) => console.error('Error deleting clip:', err));
|
||||
console.log('Deleting clip:', meta);
|
||||
}
|
||||
|
||||
async function handleClipMove(targetCollection: string, meta: ClipMetadata) {
|
||||
console.log('Moving clip:', meta, 'to collection:', targetCollection);
|
||||
dispatch({
|
||||
type: 'metadata/moveClip',
|
||||
payload: { sourceCollection: collection, targetCollection, clip: meta },
|
||||
});
|
||||
fetch('http://localhost:5010/meta/collection/clips/move', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sourceCollection: collection,
|
||||
targetCollection,
|
||||
clip: meta,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((err) => console.error('Error moving clip:', err));
|
||||
}
|
||||
|
||||
async function handleClipSave(meta: ClipMetadata) {
|
||||
try {
|
||||
dispatch({
|
||||
@ -85,7 +133,7 @@ export default function ClipList({ collection }: ClipListProps) {
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
await response.json();
|
||||
// console.log('handle clip save return:', data.collections);
|
||||
dispatch({
|
||||
type: 'metadata/editClip',
|
||||
@ -97,24 +145,42 @@ export default function ClipList({ collection }: ClipListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center bg-midnight text-offwhite">
|
||||
<div className="min-h-full flex flex-col justify-start bg-midnight text-offwhite">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<SortableContext
|
||||
items={metadata.map((item) => item.filename)}
|
||||
items={metadata.clips.map((item) => item.filename)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{metadata.map((trimmer) => (
|
||||
{metadata.clips.map((trimmer, idx) => (
|
||||
<React.Fragment key={trimmer.filename}>
|
||||
<AudioTrimmer
|
||||
metadata={trimmer}
|
||||
onSave={handleClipSave}
|
||||
onDelete={handleDelete}
|
||||
onMove={handleClipMove}
|
||||
/>
|
||||
{(idx + 1) % 10 === 0 && idx !== metadata.clips.length - 1 && (
|
||||
<div className="my-4 border-t border-gray-500">
|
||||
<p className="text-center text-sm text-gray-400">
|
||||
-- Page {Math.ceil((idx + 1) / 10) + 1} --
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{/* {metadata.map((trimmer) => (
|
||||
<AudioTrimmer
|
||||
key={trimmer.filename}
|
||||
filename={trimmer.filename}
|
||||
onSave={handleClipSave}
|
||||
/>
|
||||
))}
|
||||
))} */}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user