metadata fixes, redux start. Lifecycle fixes for regions, etc

This commit is contained in:
michalcourson
2026-02-17 18:10:38 -05:00
parent f9fdfb629b
commit d6f4d4166b
22 changed files with 1481 additions and 248 deletions

View File

@ -2,16 +2,12 @@
"Test": [], "Test": [],
"Uncategorized": [ "Uncategorized": [
{ {
"endTime": 20.085836909871187,
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260214_114317.wav", "filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260214_114317.wav",
"name": "Clip 20260214_114317", "name": "Farts",
"playbackType": "playStop", "playbackType": "playStop",
"startTime": 17.124463519313306,
"volume": 0.8 "volume": 0.8
},
{
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260214_114712.wav",
"name": "Clip 20260214_114712",
"playbackType": "playStop",
"volume": 1.0
} }
] ]
} }

Binary file not shown.

View File

@ -96,16 +96,21 @@ class AudioRecorder:
wavfile.write(filename, int(self.sample_rate), audio_data_int16) wavfile.write(filename, int(self.sample_rate), audio_data_int16)
meta = MetaDataManager() meta = MetaDataManager()
meta.add_clip_to_collection("Uncategorized", clip_metadata = {
{
"filename": filename, "filename": filename,
"name": f"Clip {timestamp}", "name": f"Clip {timestamp}",
"playbackType":"playStop", "playbackType":"playStop",
"volume": 1.0, "volume": 1.0,
}) }
return filename meta.add_clip_to_collection("Uncategorized",
{
clip_metadata
})
return clip_metadata
def set_buffer_duration(self, duration): def set_buffer_duration(self, duration):
""" """

View File

@ -8,14 +8,19 @@ from metadata_manager import MetaDataManager
from settings import SettingsManager from settings import SettingsManager
from flask import Flask from flask import Flask
from flask_cors import CORS
from routes.recording import recording_bp from routes.recording import recording_bp
from routes.device import device_bp from routes.device import device_bp
from routes.metadata import metadata_bp from routes.metadata import metadata_bp
from routes.settings import settings_bp from routes.settings import settings_bp
from flask_socketio import SocketIO
import threading import threading
app = Flask(__name__) app = Flask(__name__)
CORS(app)
# socketio = SocketIO(app, cors_allowed_origins="*")
# CORS(socketio)
def main(): def main():
# Create argument parser # Create argument parser
@ -42,6 +47,7 @@ def main():
app.register_blueprint(metadata_bp) app.register_blueprint(metadata_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.run(host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True) app.run(host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True)
# socketio.run(app, host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True)

View File

@ -0,0 +1,20 @@
{
"Uncategorized": [
{
"endTime": 12.489270386266055,
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\src\\recordings\\audio_capture_20260214_133540.wav",
"name": "Clip 20260214_133540",
"playbackType": "playStop",
"startTime": 10.622317596566523,
"volume": 1
},
{
"endTime": 6.824034334763957,
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\src\\recordings\\audio_capture_20260214_133137.wav",
"name": "Clip 20260214_133137",
"playbackType": "playStop",
"startTime": 3.7982832618025753,
"volume": 1
}
]
}

View File

@ -4,6 +4,12 @@ from metadata_manager import MetaDataManager
metadata_bp = Blueprint('metadata', __name__) metadata_bp = Blueprint('metadata', __name__)
@metadata_bp.route('/meta', methods=['GET'])
def get_allmetadata():
meta_manager = MetaDataManager()
collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
@metadata_bp.route('/meta/collections', methods=['GET']) @metadata_bp.route('/meta/collections', methods=['GET'])
def get_collections(): def get_collections():
meta_manager = MetaDataManager() meta_manager = MetaDataManager()
@ -16,7 +22,8 @@ def add_collection():
collection_name = request.json.get('name') collection_name = request.json.get('name')
try: try:
meta_manager.create_collection(collection_name) meta_manager.create_collection(collection_name)
return jsonify({'status': 'success', 'collection': collection_name}) collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
@ -30,14 +37,15 @@ def get_clips_in_collection(name):
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
@metadata_bp.route('/meta/collection/clips/reorder', methods=['POST']): @metadata_bp.route('/meta/collection/clips/reorder', methods=['POST'])
def reorder_clips_in_collection(): def reorder_clips_in_collection():
meta_manager = MetaDataManager() meta_manager = MetaDataManager()
collection_name = request.json.get('name') collection_name = request.json.get('name')
new_order = request.json.get('clips') new_order = request.json.get('clips')
try: try:
meta_manager.reorder_clips_in_collection(collection_name, new_order) meta_manager.reorder_clips_in_collection(collection_name, new_order)
return jsonify({'status': 'success', 'clips': new_order}) collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
@ -48,8 +56,8 @@ def add_clip_to_collection():
clip_metadata = request.json.get('clip') clip_metadata = request.json.get('clip')
try: try:
meta_manager.add_clip_to_collection(collection_name, clip_metadata) meta_manager.add_clip_to_collection(collection_name, clip_metadata)
clips = meta_manager.get_clips_in_collection(collection_name) collections = meta_manager.collections
return jsonify({'status': 'success', 'clips': clips}) return jsonify({'status': 'success', 'collections': collections})
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
@ -70,9 +78,10 @@ def edit_clip_in_collection():
meta_manager = MetaDataManager() meta_manager = MetaDataManager()
collection_name = request.json.get('name') collection_name = request.json.get('name')
clip_metadata = request.json.get('clip') clip_metadata = request.json.get('clip')
print(f"Received request to edit clip in collection '{collection_name}': {clip_metadata}")
try: try:
meta_manager.edit_clip_in_collection(collection_name, clip_metadata) meta_manager.edit_clip_in_collection(collection_name, clip_metadata)
clips = meta_manager.get_clips_in_collection(collection_name) collections = meta_manager.collections
return jsonify({'status': 'success', 'clips': clips}) return jsonify({'status': 'success', 'collections': collections})
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400

View File

@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from audio_recorder import AudioRecorder from audio_recorder import AudioRecorder
import os
recording_bp = Blueprint('recording', __name__) recording_bp = Blueprint('recording', __name__)
@ -31,4 +32,13 @@ def recording_status():
recorder = AudioRecorder() recorder = AudioRecorder()
print('HTTP: Checking recording status') print('HTTP: Checking recording status')
status = 'recording' if recorder.is_recording() else 'stopped' status = 'recording' if recorder.is_recording() else 'stopped'
return jsonify({'status': status}) return jsonify({'status': status})
@recording_bp.route('/record/delete', methods=['POST'])
def recording_delete():
filename = request.json.get('filename')
try:
os.remove(filename)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 400

View File

@ -0,0 +1,10 @@
{
"input_device": {
"index": 0,
"name": "Microsoft Sound Mapper - Input",
"max_input_channels": 2,
"default_samplerate": 44100.0
},
"save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\src\\recordings",
"recording_length": 15
}

File diff suppressed because it is too large Load Diff

View File

@ -107,8 +107,10 @@
"@electron/notarize": "^3.0.0", "@electron/notarize": "^3.0.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@material-tailwind/react": "^2.1.10",
"@mui/icons-material": "^7.3.7", "@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7", "@mui/material": "^7.3.7",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/cli": "^4.1.18", "@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@wavesurfer/react": "^1.0.12", "@wavesurfer/react": "^1.0.12",
@ -117,7 +119,11 @@
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"socketio": "^1.0.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"wavesurfer.js": "^7.12.1" "wavesurfer.js": "^7.12.1"
}, },

View File

@ -1,12 +1,5 @@
const AudioChannels = { const AudioChannels = {
LOAD_AUDIO_BUFFER: 'audio:loadAudioBuffer', LOAD_AUDIO_BUFFER: 'audio:loadAudioBuffer',
TRIM_FILE: 'audio:trimFile',
DELETE_FILE: 'audio:deleteFile',
GET_COLLECTION_METADATA: 'audio:getCollectionMetadata',
SET_COLLECTION_METADATA: 'audio:setCollectionMetadata',
ADD_COLLECTION: 'audio:addCollection',
REMOVE_COLLECTION: 'audio:removeCollection',
GET_COLLECTIONS: 'audio:getCollections',
} as const; } as const;
export default AudioChannels; export default AudioChannels;

View File

@ -6,27 +6,3 @@ export interface LoadAudioBufferResult {
buffer?: Buffer; buffer?: Buffer;
error?: string; error?: string;
} }
export enum PlaybackType {
PlayStop = 'playStop',
PlayOverlap = 'playOverlap',
}
export interface ClipMetadata {
name: string;
filePath: string;
volume: number;
startTime: number | undefined;
endTime: number | undefined;
playbackType: PlaybackType;
}
export interface CollectionMetadata {
name: string;
clips: ClipMetadata[];
}
export interface MetadataFile {
uncategorizedClips: ClipMetadata[];
collections: CollectionMetadata[];
}

View File

@ -0,0 +1,62 @@
import { createSlice, configureStore } from '@reduxjs/toolkit';
import { ClipMetadata, MetadataState } from './types';
const initialState: MetadataState = {
collections: {},
};
const metadataSlice = createSlice({
name: 'metadata',
initialState,
reducers: {
setAllData(state, action) {
state.collections = action.payload.collections;
},
setCollections(state, action) {
const { collection, newMetadata } = action.payload;
state.collections[collection] = newMetadata;
},
editClip(state, action) {
const { collection, clip } = action.payload;
const clips = state.collections[collection];
// console.log('Editing clip in collection:', collection, clip);
if (clips) {
const index = clips.findIndex((c) => c.filename === clip.filename);
if (index !== -1) {
clips[index] = clip;
}
}
},
addNewClips(state, action) {
const { collections } = action.payload;
Object.keys(collections).forEach((collection) => {
if (!state.collections[collection]) {
state.collections[collection] = [];
}
const existingFilenames = new Set(
state.collections[collection].map((clip) => clip.filename),
);
const newClips = collections[collection].filter(
(clip: ClipMetadata) => !existingFilenames.has(clip.filename),
);
state.collections[collection].push(...newClips);
});
},
},
});
export const store = configureStore({
reducer: metadataSlice.reducer,
});
// Can still subscribe to the store
// store.subscribe(() => console.log(store.getState()));
// Get the type of our store variable
export type AppStore = typeof store;
// Infer the `RootState` and `AppDispatch` types from the store itself
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 default metadataSlice.reducer;

View File

@ -0,0 +1,17 @@
export enum PlaybackType {
PlayStop = 'playStop',
PlayOverlap = 'playOverlap',
}
export interface ClipMetadata {
name: string;
filename: string;
volume: number;
startTime: number | undefined;
endTime: number | undefined;
playbackType: PlaybackType;
}
export interface MetadataState {
collections: Record<string, ClipMetadata[]>;
}

View File

@ -1,19 +1,52 @@
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Provider } from 'react-redux';
// import 'tailwindcss/tailwind.css'; // import 'tailwindcss/tailwind.css';
import icon from '../../assets/icon.svg'; import icon from '../../assets/icon.svg';
import './App.css'; import './App.css';
import ClipList from './components/ClipList'; import ClipList from './components/ClipList';
import { useAppDispatch } from './hooks';
import { store } from '../redux/main';
function MainPage() { function MainPage() {
return <ClipList />; const [collection, setCollection] = useState<string | null>('Uncategorized');
const dispatch = useAppDispatch();
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 });
} catch (error) {
console.error('Error fetching metadata:', error);
}
};
fetchMetadata();
// 1. Set up the interval
const intervalId = setInterval(async () => {
fetchMetadata();
}, 5000); // 1000 milliseconds delay
// 2. Return a cleanup function to clear the interval when the component unmounts
return () => {
clearInterval(intervalId);
};
}, [dispatch]); //
return <ClipList collection={collection} />;
} }
export default function App() { export default function App() {
return ( return (
<Router> <Provider store={store}>
<Routes> <Router>
<Route path="/" element={<MainPage />} /> <Routes>
</Routes> <Route path="/" element={<MainPage />} />
</Router> </Routes>
</Router>
</Provider>
); );
} }

View File

@ -5,6 +5,10 @@ import React, {
useCallback, useCallback,
useRef, useRef,
} from 'react'; } from 'react';
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 { useWavesurfer } from '@wavesurfer/react'; import { useWavesurfer } from '@wavesurfer/react';
import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js'; import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js';
import ZoomPlugin from 'wavesurfer.js/dist/plugins/zoom.esm.js'; import ZoomPlugin from 'wavesurfer.js/dist/plugins/zoom.esm.js';
@ -14,26 +18,53 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { ClipMetadata } from '../../ipc/audio/types'; import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
import { ClipMetadata } from '../../redux/types';
import { useAppSelector } from '../hooks';
export interface AudioTrimmerProps { export interface AudioTrimmerProps {
metadata: ClipMetadata; filename: string;
onSave?: (trimStart: number, trimEnd: number, title?: string) => void; onSave?: (metadata: ClipMetadata) => void;
onDelete?: () => void; onDelete?: () => void;
} }
export default function AudioTrimmer({ export default function AudioTrimmer({
metadata, filename,
onSave, onSave,
onDelete, onDelete,
}: AudioTrimmerProps) { }: AudioTrimmerProps) {
const { attributes, listeners, setNodeRef, transform, transition } = const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: metadata.filePath }); useSortable({ id: 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 [nameInput, setNameInput] = useState<string>(metadata.name);
useEffect(() => {
setNameInput(metadata.name);
}, [metadata.name]);
const openEditDialog = () => setEditDialogOpen(true);
const closeEditDialog = () => setEditDialogOpen(false);
const handleDialogSave = () => {
if (nameInput.trim() && nameInput !== metadata.name) {
const updated = { ...metadata, name: nameInput.trim() };
if (onSave) onSave(updated);
}
closeEditDialog();
};
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined); const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const containerRef = useRef(null); const containerRef = useRef(null);
const [clipStart, setClipStart] = useState<number>(0); // const [clipStart, setClipStart] = useState<number | undefined>(undefined);
const [clipEnd, setClipEnd] = useState<number>(0); // const [clipEnd, setClipEnd] = useState<number | undefined>(undefined);
const plugins = useMemo( const plugins = useMemo(
() => [ () => [
@ -46,7 +77,7 @@ export default function AudioTrimmer({
); );
const fileBaseName = const fileBaseName =
metadata.filePath.split('\\').pop()?.split('/').pop() || 'Unknown'; metadata.filename.split('\\').pop()?.split('/').pop() || 'Unknown';
const { wavesurfer, isReady, isPlaying } = useWavesurfer({ const { wavesurfer, isReady, isPlaying } = useWavesurfer({
container: containerRef, container: containerRef,
@ -58,56 +89,123 @@ export default function AudioTrimmer({
plugins, plugins,
}); });
// Add this ref to always have the latest metadata
const metadataRef = useRef(metadata);
useEffect(() => {
metadataRef.current = metadata;
}, [metadata]);
const onRegionCreated = useCallback( const onRegionCreated = useCallback(
(newRegion: any) => { (newRegion: any) => {
if (wavesurfer === null) return; if (wavesurfer === null) return;
const allRegions = plugins[0].getRegions();
const allRegions = (plugins[0] as RegionsPlugin).getRegions();
let isNew = metadataRef.current.startTime === undefined;
allRegions.forEach((region) => { allRegions.forEach((region) => {
if (region.id !== newRegion.id) { if (region.id !== newRegion.id) {
if (
region.start === newRegion.start &&
region.end === newRegion.end
) {
newRegion.remove();
return;
}
region.remove(); region.remove();
isNew = !(region.start === 0 && region.end === 0);
// console.log('Region replace:', newRegion, region);
} }
}); });
setClipStart(newRegion.start);
setClipEnd(newRegion.end); if (isNew) {
console.log('Region created:', metadataRef.current);
const updated = {
...metadataRef.current,
startTime: newRegion.start,
endTime: newRegion.end,
};
if (onSave) {
onSave(updated);
}
}
}, },
[plugins, wavesurfer], [plugins, wavesurfer, onSave],
);
const onRegionUpdated = useCallback(
(newRegion: any) => {
if (wavesurfer === null) return;
const updated = {
...metadataRef.current,
startTime: newRegion.start,
endTime: newRegion.end,
};
if (onSave) {
onSave(updated);
}
},
[onSave, wavesurfer],
); );
useEffect(() => { useEffect(() => {
console.log('ready, setting up regions plugin', wavesurfer); const plugin = plugins[0] as RegionsPlugin;
if (metadata.startTime !== undefined && metadata.endTime !== undefined) {
setClipStart(metadata.startTime);
setClipEnd(metadata.endTime);
plugins[0].addRegion({
start: metadata.startTime,
end: metadata.endTime,
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
});
} else {
setClipStart(0);
setClipEnd(wavesurfer ? wavesurfer.getDuration() : 0);
}
plugins[0].enableDragSelection({ if (!isReady) return;
// console.log('ready, setting up regions plugin', plugin, isReady);
if (
metadataRef.current.startTime !== undefined &&
metadataRef.current.endTime !== undefined
) {
// setClipStart(metadata.startTime);
// setClipEnd(metadata.endTime);
// console.log('Adding region from metadata:', metadata);=
const allRegions = plugin.getRegions();
// console.log('Existing regions:', allRegions);
if (
allRegions.length === 0 ||
(allRegions.length === 1 &&
allRegions[0].start === 0 &&
allRegions[0].end === 0)
) {
// console.log('adding region from metadata:', metadataRef.current);
plugin.addRegion({
start: metadataRef.current.startTime,
end: metadataRef.current.endTime,
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
});
}
} else {
// setClipStart(0);
// setClipEnd(wavesurfer ? wavesurfer.getDuration() : 0);
}
}, [isReady, plugins]);
useEffect(() => {
const plugin = plugins[0] as RegionsPlugin;
plugin.unAll();
plugin.on('region-created', onRegionCreated);
plugin.on('region-updated', onRegionUpdated);
plugin.enableDragSelection({
color: 'rgba(132, 81, 224, 0.3)', color: 'rgba(132, 81, 224, 0.3)',
}); });
plugins[0].on('region-created', onRegionCreated); }, [onRegionCreated, onRegionUpdated, plugins]);
}, [isReady, plugins, wavesurfer, onRegionCreated, metadata]);
useEffect(() => { useEffect(() => {
let url: string | null = null; let url: string | null = null;
async function fetchAudio() { async function fetchAudio() {
// console.log('Loading audio buffer for file:', filePath); // console.log('Loading audio buffer for file:', filename);
const buffer = await window.audio.loadAudioBuffer(metadata.filePath); const buffer = await window.audio.loadAudioBuffer(metadata.filename);
console.log('Received buffer:', buffer.buffer); // console.log('Received buffer:', buffer.buffer);
if (buffer.buffer && !buffer.error) { if (buffer.buffer && !buffer.error) {
const audioData = buffer.buffer const audioData = buffer.buffer
? new Uint8Array(buffer.buffer) ? new Uint8Array(buffer.buffer)
: buffer; : buffer;
url = URL.createObjectURL(new Blob([audioData])); url = URL.createObjectURL(new Blob([audioData]));
console.log('Created blob URL:', url); // console.log('Created blob URL:', url);
setBlobUrl(url); setBlobUrl(url);
} }
} }
@ -115,14 +213,14 @@ export default function AudioTrimmer({
return () => { return () => {
if (url) URL.revokeObjectURL(url); if (url) URL.revokeObjectURL(url);
}; };
}, [metadata.filePath]); }, [metadata.filename]);
const onPlayPause = () => { const onPlayPause = () => {
if (wavesurfer === null) return; if (wavesurfer === null) return;
if (isPlaying) { if (isPlaying) {
wavesurfer.pause(); wavesurfer.pause();
} else { } else {
const allRegions = plugins[0].getRegions(); const allRegions = (plugins[0] as RegionsPlugin).getRegions();
if (allRegions.length > 0) { if (allRegions.length > 0) {
wavesurfer.play(allRegions[0].start, allRegions[0].end); wavesurfer.play(allRegions[0].start, allRegions[0].end);
} else { } else {
@ -149,7 +247,9 @@ export default function AudioTrimmer({
className="shadow-[0_2px_8px_rgba(0,0,0,0.5)] m-2 rounded-lg bg-darkDrop" className="shadow-[0_2px_8px_rgba(0,0,0,0.5)] m-2 rounded-lg bg-darkDrop"
> >
<div <div
// eslint-disable-next-line react/jsx-props-no-spreading
{...attributes} {...attributes}
// eslint-disable-next-line react/jsx-props-no-spreading
{...listeners} {...listeners}
style={{ style={{
position: 'absolute', position: 'absolute',
@ -166,9 +266,63 @@ export default function AudioTrimmer({
<div className="ml-4 mr-2 p-2"> <div className="ml-4 mr-2 p-2">
<div className="grid justify-items-stretch grid-cols-2"> <div className="grid justify-items-stretch grid-cols-2">
<div className="mb-5px flex flex-col"> <div className="mb-5px flex flex-col">
<text className="font-bold text-lg">{metadata.name}</text> <span
className="font-bold text-lg text-white mb-1 cursor-pointer"
onClick={openEditDialog}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openEditDialog();
}
}}
title="Click to edit name"
tabIndex={0}
role="button"
style={{ outline: 'none' }}
>
{metadata.name}
</span>
<text className="text-sm text-neutral-500">{fileBaseName}</text> <text className="text-sm text-neutral-500">{fileBaseName}</text>
</div> </div>
<Dialog
open={editDialogOpen}
onClose={closeEditDialog}
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={nameInput}
onChange={(e) => setNameInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleDialogSave();
}}
aria-label="Edit clip name"
/>
</DialogContent>
<DialogActions>
<button
type="button"
onClick={closeEditDialog}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Cancel
</button>
<button
type="button"
onClick={handleDialogSave}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Save
</button>
</DialogActions>
</Dialog>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="button" type="button"
@ -198,7 +352,8 @@ export default function AudioTrimmer({
<div className="grid justify-items-stretch grid-cols-2 text-neutral-500"> <div className="grid justify-items-stretch grid-cols-2 text-neutral-500">
<div className="m-1 flex justify-start"> <div className="m-1 flex justify-start">
<text className="text-sm "> <text className="text-sm ">
Clip: {formatTime(clipStart)} - {formatTime(clipEnd)} Clip: {formatTime(metadata.startTime ?? 0)} -{' '}
{formatTime(metadata.endTime ?? 0)}
</text> </text>
</div> </div>
</div> </div>

View File

@ -1,6 +1,3 @@
import { use, useEffect, useState } from 'react';
import AudioTrimmer from './AudioTrimer';
import { ClipMetadata, PlaybackType } from '../../ipc/audio/types';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@ -12,99 +9,90 @@ import {
arrayMove, arrayMove,
SortableContext, SortableContext,
verticalListSortingStrategy, verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import AudioTrimmer from './AudioTrimer';
import { ClipMetadata } from '../../redux/types';
import { useAppDispatch, useAppSelector } from '../hooks';
export interface ClipListProps { export interface ClipListProps {
collection: string; collection: string;
} }
const testData: ClipMetadata[] = [
{
name: 'test 1',
filePath:
'C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav',
volume: 1,
startTime: undefined,
endTime: undefined,
playbackType: PlaybackType.PlayStop,
},
{
name: 'test 2',
filePath:
'C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250105_131700.wav',
volume: 1,
startTime: undefined,
endTime: undefined,
playbackType: PlaybackType.PlayStop,
},
{
name: 'test 3',
filePath:
'C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250117_194006.wav',
volume: 1,
startTime: undefined,
endTime: undefined,
playbackType: PlaybackType.PlayStop,
},
];
function SortableTrimmer({ trimmer, id }) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
marginBottom: '1rem',
};
return (
<div ref={setNodeRef} style={style}>
{/* Only this div is draggable */}
<div
{...attributes}
{...listeners}
style={{
cursor: 'grab',
padding: '4px',
background: '#6e44ba',
color: 'white',
borderRadius: '4px',
marginBottom: '8px',
width: 'fit-content',
}}
>
Drag
</div>
{/* Wavesurfer and rest of AudioTrimmer are NOT draggable */}
<AudioTrimmer metadata={trimmer} />
</div>
);
}
export default function ClipList({ collection }: ClipListProps) { export default function ClipList({ collection }: ClipListProps) {
const [metadata, setMetadata] = useState<ClipMetadata[]>(testData); const metadata = useAppSelector(
useEffect(() => { (state) => state.collections[collection] || [],
setMetadata(testData); );
}, [collection]); const dispatch = useAppDispatch();
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
); );
function handleDragEnd(event) { async function handleDragEnd(event) {
const { active, over } = event; const { active, over } = event;
if (active.id !== over?.id) { if (active.id !== over?.id) {
const oldIndex = metadata.findIndex( const oldIndex = metadata.findIndex(
(item) => item.filePath === active.id, (item) => item.filename === active.id,
); );
const newIndex = metadata.findIndex((item) => item.filePath === over.id); const newIndex = metadata.findIndex((item) => item.filename === over.id);
const newMetadata = arrayMove(metadata, oldIndex, newIndex); const newMetadata = arrayMove(metadata, oldIndex, newIndex);
console.log('New order:', newMetadata); console.log('New order:', newMetadata);
setMetadata(newMetadata); dispatch({
type: 'metadata/setCollections',
payload: { collection, newMetadata },
});
try {
const response = await fetch(
'http://localhost:5010/meta/collection/clips/reorder',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: collection,
clips: newMetadata,
}),
},
);
const data = await response.json();
console.log('handle reorder return:', data.collections);
dispatch({ type: 'metadata/setAllData', payload: data });
} catch (error) {
console.error('Error saving new clip order:', error);
}
// setMetadata(newMetadata);
}
}
async function handleClipSave(meta: ClipMetadata) {
try {
dispatch({
type: 'metadata/editClip',
payload: { collection, clip: meta },
});
const response = await fetch(
'http://localhost:5010/meta/collection/clips/edit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: collection,
clip: meta,
}),
},
);
const data = await response.json();
// console.log('handle clip save return:', data.collections);
dispatch({
type: 'metadata/editClip',
payload: { collection, clip: meta },
});
} catch (error) {
console.error('Error saving clip metadata:', error);
} }
} }
@ -117,11 +105,15 @@ export default function ClipList({ collection }: ClipListProps) {
modifiers={[restrictToVerticalAxis]} modifiers={[restrictToVerticalAxis]}
> >
<SortableContext <SortableContext
items={metadata.map((item) => item.filePath)} items={metadata.map((item) => item.filename)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{metadata.map((trimmer) => ( {metadata.map((trimmer) => (
<AudioTrimmer key={trimmer.filePath} metadata={trimmer} /> <AudioTrimmer
key={trimmer.filename}
filename={trimmer.filename}
onSave={handleClipSave}
/>
))} ))}
</SortableContext> </SortableContext>
</DndContext> </DndContext>

View File

@ -0,0 +1,6 @@
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { AppDispatch, RootState } from '../redux/main';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<RootState>();