start of react migration

This commit is contained in:
michalcourson
2026-02-04 20:42:14 -05:00
parent 51ad065047
commit 17bace5eaf
71 changed files with 26503 additions and 6037 deletions

View File

@ -0,0 +1,818 @@
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);
}
});