loukai-app 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +558 -0
- package/bin/loukai.js +32 -0
- package/package.json +243 -0
- package/src/main/appState.js +250 -0
- package/src/main/audioEngine.js +478 -0
- package/src/main/creator/conversionService.js +503 -0
- package/src/main/creator/downloadManager.js +1128 -0
- package/src/main/creator/ffmpegService.js +487 -0
- package/src/main/creator/installLogger.js +51 -0
- package/src/main/creator/keyDetection.js +212 -0
- package/src/main/creator/llmService.js +370 -0
- package/src/main/creator/lrclibService.js +340 -0
- package/src/main/creator/python/crepe_runner.py +189 -0
- package/src/main/creator/python/demucs_runner.py +158 -0
- package/src/main/creator/python/whisper_runner.py +172 -0
- package/src/main/creator/pythonRunner.js +268 -0
- package/src/main/creator/stemBuilder.js +491 -0
- package/src/main/creator/systemChecker.js +474 -0
- package/src/main/handlers/appHandlers.js +45 -0
- package/src/main/handlers/audioHandlers.js +33 -0
- package/src/main/handlers/autotuneHandlers.js +28 -0
- package/src/main/handlers/canvasHandlers.js +84 -0
- package/src/main/handlers/creatorHandlers.js +159 -0
- package/src/main/handlers/editorHandlers.js +98 -0
- package/src/main/handlers/effectsHandlers.js +100 -0
- package/src/main/handlers/fileHandlers.js +45 -0
- package/src/main/handlers/index.js +78 -0
- package/src/main/handlers/libraryHandlers.js +96 -0
- package/src/main/handlers/mixerHandlers.js +64 -0
- package/src/main/handlers/playerHandlers.js +39 -0
- package/src/main/handlers/preferencesHandlers.js +46 -0
- package/src/main/handlers/queueHandlers.js +81 -0
- package/src/main/handlers/rendererHandlers.js +63 -0
- package/src/main/handlers/settingsHandlers.js +42 -0
- package/src/main/handlers/webServerHandlers.js +105 -0
- package/src/main/main.js +2351 -0
- package/src/main/preload.js +252 -0
- package/src/main/settingsManager.js +139 -0
- package/src/main/statePersistence.js +193 -0
- package/src/main/utils/pathValidator.js +112 -0
- package/src/main/webServer.js +2535 -0
- package/src/native/autotune.js +417 -0
- package/src/renderer/adapters/ElectronBridge.js +677 -0
- package/src/renderer/canvas.html +80 -0
- package/src/renderer/components/App.jsx +303 -0
- package/src/renderer/components/AppRoot.jsx +37 -0
- package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
- package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
- package/src/renderer/components/MixerTab.jsx +233 -0
- package/src/renderer/components/MixerTabWrapper.jsx +31 -0
- package/src/renderer/components/PortalSelect.jsx +239 -0
- package/src/renderer/components/QueueTab.jsx +116 -0
- package/src/renderer/components/RequestsListWrapper.jsx +78 -0
- package/src/renderer/components/ServerTab.jsx +472 -0
- package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
- package/src/renderer/components/StatusBar.jsx +92 -0
- package/src/renderer/components/TabNavigation.jsx +77 -0
- package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
- package/src/renderer/components/creator/CreateTab.jsx +1236 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
- package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
- package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/dist/renderer.css +1 -0
- package/src/renderer/dist/renderer.js +62 -0
- package/src/renderer/dist/renderer.js.map +1 -0
- package/src/renderer/dist/renderer.woff2 +0 -0
- package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
- package/src/renderer/index.html +24 -0
- package/src/renderer/index.html.backup +372 -0
- package/src/renderer/js/PlayerInterface.js +267 -0
- package/src/renderer/js/autoTuneWorklet.js +224 -0
- package/src/renderer/js/butterchurnVerify.js +46 -0
- package/src/renderer/js/canvas-app.js +114 -0
- package/src/renderer/js/cdgPlayer.js +685 -0
- package/src/renderer/js/kaiPlayer.js +1200 -0
- package/src/renderer/js/karaokeRenderer.js +3392 -0
- package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/js/microphoneEngine.js +656 -0
- package/src/renderer/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/js/player.js +232 -0
- package/src/renderer/js/referencePitchTracker.js +130 -0
- package/src/renderer/js/songLoaders.js +334 -0
- package/src/renderer/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/js/webrtcManager.js +511 -0
- package/src/renderer/lib/butterchurn.min.js +6739 -0
- package/src/renderer/lib/butterchurnPresets.min.js +1 -0
- package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
- package/src/renderer/lib/cdgraphics.js +299 -0
- package/src/renderer/public/js/autoTuneWorklet.js +224 -0
- package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/react-entry.jsx +44 -0
- package/src/renderer/styles/tailwind.css +106 -0
- package/src/renderer/utils/qrCodeGenerator.js +98 -0
- package/src/renderer/vite.config.js +31 -0
- package/src/shared/adapters/BridgeInterface.js +195 -0
- package/src/shared/components/EffectsPanel.jsx +177 -0
- package/src/shared/components/LibraryPanel.jsx +701 -0
- package/src/shared/components/LineDetailCanvas.jsx +167 -0
- package/src/shared/components/LyricLine.jsx +505 -0
- package/src/shared/components/LyricRejection.jsx +84 -0
- package/src/shared/components/LyricSuggestion.jsx +80 -0
- package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
- package/src/shared/components/MixerPanel.jsx +94 -0
- package/src/shared/components/PlayerControls.jsx +206 -0
- package/src/shared/components/PortalSelect.jsx +239 -0
- package/src/shared/components/QueueList.jsx +365 -0
- package/src/shared/components/QuickSearch.jsx +126 -0
- package/src/shared/components/RequestsList.jsx +121 -0
- package/src/shared/components/SongEditor.jsx +1362 -0
- package/src/shared/components/SongInfoBar.jsx +81 -0
- package/src/shared/components/ThemeToggle.jsx +106 -0
- package/src/shared/components/Toast.jsx +30 -0
- package/src/shared/components/VisualizationSettings.jsx +243 -0
- package/src/shared/constants.js +95 -0
- package/src/shared/context/BridgeContext.jsx +32 -0
- package/src/shared/contexts/AudioContext.jsx +37 -0
- package/src/shared/contexts/PlayerContext.jsx +66 -0
- package/src/shared/contexts/SettingsContext.jsx +50 -0
- package/src/shared/defaults.js +158 -0
- package/src/shared/formatUtils.js +59 -0
- package/src/shared/formatUtils.test.js +207 -0
- package/src/shared/hooks/useAppState.js +97 -0
- package/src/shared/hooks/useAudioEngine.js +264 -0
- package/src/shared/hooks/usePlayer.js +89 -0
- package/src/shared/hooks/useSettingsPersistence.js +74 -0
- package/src/shared/hooks/useWebRTC.js +118 -0
- package/src/shared/ipcContracts.js +299 -0
- package/src/shared/package.json +3 -0
- package/src/shared/services/creatorService.js +373 -0
- package/src/shared/services/creatorService.test.js +413 -0
- package/src/shared/services/editorService.js +213 -0
- package/src/shared/services/editorService.test.js +219 -0
- package/src/shared/services/effectsService.js +271 -0
- package/src/shared/services/effectsService.test.js +418 -0
- package/src/shared/services/libraryService.js +438 -0
- package/src/shared/services/libraryService.test.js +474 -0
- package/src/shared/services/mixerService.js +172 -0
- package/src/shared/services/mixerService.test.js +399 -0
- package/src/shared/services/playerService.js +221 -0
- package/src/shared/services/playerService.test.js +357 -0
- package/src/shared/services/preferencesService.js +219 -0
- package/src/shared/services/queueService.js +226 -0
- package/src/shared/services/queueService.test.js +430 -0
- package/src/shared/services/requestsService.js +155 -0
- package/src/shared/services/requestsService.test.js +362 -0
- package/src/shared/services/serverSettingsService.js +151 -0
- package/src/shared/services/settingsService.js +257 -0
- package/src/shared/services/settingsService.test.js +295 -0
- package/src/shared/state/StateManager.js +263 -0
- package/src/shared/utils/audio.js +42 -0
- package/src/shared/utils/format.js +32 -0
- package/src/shared/utils/lyricsUtils.js +162 -0
- package/src/test/setup.js +40 -0
- package/src/utils/cdgLoader.js +180 -0
- package/src/utils/m4aLoader.js +333 -0
- package/src/web/App.jsx +578 -0
- package/src/web/adapters/WebBridge.js +428 -0
- package/src/web/components/PlayerSettingsPanel.jsx +231 -0
- package/src/web/components/SongSearch.jsx +180 -0
- package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
- package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
- package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
- package/src/web/dist/index.html +15 -0
- package/src/web/index.html +14 -0
- package/src/web/main.jsx +10 -0
- package/src/web/package-lock.json +1765 -0
- package/src/web/pages/SongRequestPage.jsx +619 -0
- package/src/web/styles/tailwind.css +68 -0
- package/src/web/vite.config.js +27 -0
- package/static/fonts/material-icons.woff2 +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
- package/static/images/butterchurn-screenshots/An AdamFX n Martin Infusion 2 flexi - Why The Sky Looks Diffrent Today - AdamFx n Martin Infusion - Tack Tile Disfunction B.png +0 -0
- package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
- proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
- package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
- package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
- package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
- package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
- package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
- package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
- package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
- package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
- package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
- package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
- package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
- package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
- package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
- package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
- package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
- package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
- package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
- package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
- package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
- package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
- package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
- package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
- package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
- package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
- package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
- package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
- package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
- package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - Rovastar - Sunflower Passion _Enlightment Mix__Phat_edit _ flexi und martin shaders - circumflex in character classes in regular expression.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
- package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
- package/static/images/logo-512.png +0 -0
- package/static/images/logo.png +0 -0
- package/static/loukai-logo.png +0 -0
- package/static/screenshot-generator.html +610 -0
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SongEditor - Comprehensive song metadata and lyrics editor
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Search and load any song from library
|
|
6
|
+
* - Edit ID3 metadata for CDG+MP3 files
|
|
7
|
+
* - Edit ID3 metadata + lyrics for KAI files
|
|
8
|
+
* - Edit metadata + lyrics for M4A Stems files
|
|
9
|
+
* - Auto-detects file format and shows appropriate editing options
|
|
10
|
+
* - Supports both Electron (IPC) and Web (REST) environments
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
14
|
+
import { getFormatIcon } from '../formatUtils.js';
|
|
15
|
+
import { LyricsEditorCanvas } from './LyricsEditorCanvas.jsx';
|
|
16
|
+
import { LineDetailCanvas } from './LineDetailCanvas.jsx';
|
|
17
|
+
import { LyricLine } from './LyricLine.jsx';
|
|
18
|
+
import { Toast } from './Toast.jsx';
|
|
19
|
+
import { LyricRejection } from './LyricRejection.jsx';
|
|
20
|
+
import { LyricSuggestion } from './LyricSuggestion.jsx';
|
|
21
|
+
import { splitLine, canSplitLine } from '../utils/lyricsUtils.js';
|
|
22
|
+
|
|
23
|
+
export function SongEditor({ bridge }) {
|
|
24
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
25
|
+
const [searchResults, setSearchResults] = useState([]);
|
|
26
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
27
|
+
const [loadedSong, setLoadedSong] = useState(null);
|
|
28
|
+
const [songData, setSongData] = useState(null);
|
|
29
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
30
|
+
const [activeTab, setActiveTab] = useState('metadata'); // 'metadata' or 'lyrics'
|
|
31
|
+
|
|
32
|
+
// Metadata form state
|
|
33
|
+
const [metadata, setMetadata] = useState({
|
|
34
|
+
title: '',
|
|
35
|
+
artist: '',
|
|
36
|
+
album: '',
|
|
37
|
+
year: '',
|
|
38
|
+
genre: '',
|
|
39
|
+
key: '',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Lyrics state (for KAI files) - now array format for full editing
|
|
43
|
+
const [lyricsData, setLyricsData] = useState([]);
|
|
44
|
+
const [originalLyricsData, setOriginalLyricsData] = useState([]);
|
|
45
|
+
const [selectedLineIndex, setSelectedLineIndex] = useState(null);
|
|
46
|
+
const [songDuration, setSongDuration] = useState(0);
|
|
47
|
+
const [rejections, setRejections] = useState([]);
|
|
48
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
49
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
50
|
+
|
|
51
|
+
// Audio playback state (for KAI files)
|
|
52
|
+
const [audioElements, setAudioElements] = useState([]);
|
|
53
|
+
const [audioContext, setAudioContext] = useState(null);
|
|
54
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
55
|
+
const [currentPosition, setCurrentPosition] = useState(0);
|
|
56
|
+
const [playingLineEndTime, setPlayingLineEndTime] = useState(null);
|
|
57
|
+
|
|
58
|
+
// Waveform visualization
|
|
59
|
+
const [vocalsWaveform, setVocalsWaveform] = useState(null);
|
|
60
|
+
|
|
61
|
+
// Animation frame ref for smooth playhead
|
|
62
|
+
const animationFrameRef = useRef(null);
|
|
63
|
+
|
|
64
|
+
// Toast notification state
|
|
65
|
+
const [toast, setToast] = useState(null);
|
|
66
|
+
|
|
67
|
+
// Check if a line overlaps with the previous line (for same singer)
|
|
68
|
+
const checkOverlap = useCallback(
|
|
69
|
+
(index) => {
|
|
70
|
+
if (index === 0 || index >= lyricsData.length) {
|
|
71
|
+
return false; // First line can't overlap, or invalid index
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const currentLine = lyricsData[index];
|
|
75
|
+
const previousLine = lyricsData[index - 1];
|
|
76
|
+
|
|
77
|
+
// Get singer type (backup vs lead)
|
|
78
|
+
const currentIsBackup = currentLine.backup === true;
|
|
79
|
+
const previousIsBackup = previousLine.backup === true;
|
|
80
|
+
|
|
81
|
+
// Only check overlap if same singer type
|
|
82
|
+
if (currentIsBackup !== previousIsBackup) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Get timing values
|
|
87
|
+
const currentStart = currentLine.start || currentLine.startTimeSec || 0;
|
|
88
|
+
const previousEnd = previousLine.end || previousLine.endTimeSec || 0;
|
|
89
|
+
|
|
90
|
+
// Overlap occurs when current starts before previous ends
|
|
91
|
+
return currentStart < previousEnd;
|
|
92
|
+
},
|
|
93
|
+
[lyricsData]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Lyrics editing handlers - wrap in useCallback for stable references
|
|
97
|
+
const handleLineUpdate = useCallback((index, updatedLine) => {
|
|
98
|
+
setLyricsData((prev) => prev.map((line, i) => (i === index ? updatedLine : line)));
|
|
99
|
+
setHasChanges(true);
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const handlePlayLineSection = useCallback(
|
|
103
|
+
async (startTime, endTime) => {
|
|
104
|
+
if (!audioElements.length) {
|
|
105
|
+
console.warn('No audio elements available for playback');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`🎵 Playing section: ${startTime}s - ${endTime}s`);
|
|
110
|
+
|
|
111
|
+
// Clear end time first to prevent premature stop
|
|
112
|
+
setPlayingLineEndTime(null);
|
|
113
|
+
|
|
114
|
+
// Set audio position
|
|
115
|
+
audioElements.forEach(({ audio }) => {
|
|
116
|
+
audio.currentTime = startTime;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Immediately update currentPosition state so canvas shows correct position
|
|
120
|
+
setCurrentPosition(startTime);
|
|
121
|
+
|
|
122
|
+
// Start playback with error handling
|
|
123
|
+
try {
|
|
124
|
+
const playPromises = audioElements.map(({ audio }) => audio.play());
|
|
125
|
+
await Promise.all(playPromises);
|
|
126
|
+
setIsPlaying(true);
|
|
127
|
+
console.log('✅ Audio playback started');
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('Failed to play audio:', error);
|
|
130
|
+
setToast({
|
|
131
|
+
message: `Failed to play audio: ${error.message}`,
|
|
132
|
+
type: 'error',
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Set end time after position has been set
|
|
138
|
+
// Use a small timeout to ensure currentTime has updated
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
setPlayingLineEndTime(endTime);
|
|
141
|
+
}, 50);
|
|
142
|
+
},
|
|
143
|
+
[audioElements]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Cleanup audio on unmount or song change
|
|
147
|
+
const cleanupAudio = useCallback(() => {
|
|
148
|
+
// Stop and cleanup existing audio
|
|
149
|
+
audioElements.forEach(({ audio, source }) => {
|
|
150
|
+
audio.pause();
|
|
151
|
+
audio.currentTime = 0;
|
|
152
|
+
try {
|
|
153
|
+
source.disconnect();
|
|
154
|
+
} catch {
|
|
155
|
+
// Ignore disconnect errors
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (audioContext) {
|
|
160
|
+
audioContext.close();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
setAudioElements([]);
|
|
164
|
+
setAudioContext(null);
|
|
165
|
+
setIsPlaying(false);
|
|
166
|
+
}, [audioElements, audioContext]);
|
|
167
|
+
|
|
168
|
+
// Navigate to previous enabled line
|
|
169
|
+
const selectPreviousEnabledLine = useCallback(() => {
|
|
170
|
+
if (selectedLineIndex === null || selectedLineIndex === 0) {
|
|
171
|
+
return; // Already at first line
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Find previous enabled line
|
|
175
|
+
for (let i = selectedLineIndex - 1; i >= 0; i--) {
|
|
176
|
+
if (!lyricsData[i].disabled) {
|
|
177
|
+
setSelectedLineIndex(i);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}, [selectedLineIndex, lyricsData]);
|
|
182
|
+
|
|
183
|
+
// Navigate to next enabled line
|
|
184
|
+
const selectNextEnabledLine = useCallback(() => {
|
|
185
|
+
if (selectedLineIndex === null) {
|
|
186
|
+
// No selection, select first enabled line
|
|
187
|
+
for (let i = 0; i < lyricsData.length; i++) {
|
|
188
|
+
if (!lyricsData[i].disabled) {
|
|
189
|
+
setSelectedLineIndex(i);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (selectedLineIndex >= lyricsData.length - 1) {
|
|
197
|
+
return; // Already at last line
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Find next enabled line
|
|
201
|
+
for (let i = selectedLineIndex + 1; i < lyricsData.length; i++) {
|
|
202
|
+
if (!lyricsData[i].disabled) {
|
|
203
|
+
setSelectedLineIndex(i);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}, [selectedLineIndex, lyricsData]);
|
|
208
|
+
|
|
209
|
+
// Play currently selected line
|
|
210
|
+
const playCurrentLine = useCallback(() => {
|
|
211
|
+
if (selectedLineIndex === null || !lyricsData[selectedLineIndex]) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const line = lyricsData[selectedLineIndex];
|
|
216
|
+
const startTime = line.start || line.startTimeSec || 0;
|
|
217
|
+
const endTime = line.end || line.endTimeSec || startTime + 3;
|
|
218
|
+
handlePlayLineSection(startTime, endTime);
|
|
219
|
+
}, [selectedLineIndex, lyricsData, handlePlayLineSection]);
|
|
220
|
+
|
|
221
|
+
// Adjust start time of selected line
|
|
222
|
+
const adjustStartTime = useCallback(
|
|
223
|
+
(delta) => {
|
|
224
|
+
if (selectedLineIndex === null || !lyricsData[selectedLineIndex]) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const line = lyricsData[selectedLineIndex];
|
|
229
|
+
const currentStart = line.start || line.startTimeSec || 0;
|
|
230
|
+
const newStart = Math.max(0, currentStart + delta); // Don't go below 0
|
|
231
|
+
|
|
232
|
+
const updatedLine = {
|
|
233
|
+
...line,
|
|
234
|
+
start: newStart,
|
|
235
|
+
startTimeSec: newStart,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
handleLineUpdate(selectedLineIndex, updatedLine);
|
|
239
|
+
},
|
|
240
|
+
[selectedLineIndex, lyricsData, handleLineUpdate]
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Adjust end time of selected line
|
|
244
|
+
const adjustEndTime = useCallback(
|
|
245
|
+
(delta) => {
|
|
246
|
+
if (selectedLineIndex === null || !lyricsData[selectedLineIndex]) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const line = lyricsData[selectedLineIndex];
|
|
251
|
+
const currentEnd = line.end || line.endTimeSec || 0;
|
|
252
|
+
const newEnd = Math.max(0, currentEnd + delta); // Don't go below 0
|
|
253
|
+
|
|
254
|
+
const updatedLine = {
|
|
255
|
+
...line,
|
|
256
|
+
end: newEnd,
|
|
257
|
+
endTimeSec: newEnd,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
handleLineUpdate(selectedLineIndex, updatedLine);
|
|
261
|
+
},
|
|
262
|
+
[selectedLineIndex, lyricsData, handleLineUpdate]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Keyboard shortcuts
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const handleKeyDown = (e) => {
|
|
268
|
+
// Ignore if typing in an input field
|
|
269
|
+
const target = e.target;
|
|
270
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Only apply shortcuts when on lyrics tab with a song loaded
|
|
275
|
+
if (activeTab !== 'lyrics' || !lyricsData.length) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Increment size: 0.1s normal, 0.5s with shift
|
|
280
|
+
const increment = e.shiftKey ? 0.5 : 0.1;
|
|
281
|
+
|
|
282
|
+
switch (e.key.toLowerCase()) {
|
|
283
|
+
case 'q': // Previous enabled line
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
selectPreviousEnabledLine();
|
|
286
|
+
break;
|
|
287
|
+
case 'o': // Next enabled line
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
selectNextEnabledLine();
|
|
290
|
+
break;
|
|
291
|
+
case 'p': // Play current line
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
playCurrentLine();
|
|
294
|
+
break;
|
|
295
|
+
case 'd': // Decrease start time
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
adjustStartTime(-increment);
|
|
298
|
+
break;
|
|
299
|
+
case 'f': // Increase start time
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
adjustStartTime(increment);
|
|
302
|
+
break;
|
|
303
|
+
case 'j': // Decrease end time
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
adjustEndTime(-increment);
|
|
306
|
+
break;
|
|
307
|
+
case 'k': // Increase end time
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
adjustEndTime(increment);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
315
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
316
|
+
}, [
|
|
317
|
+
activeTab,
|
|
318
|
+
lyricsData,
|
|
319
|
+
selectedLineIndex,
|
|
320
|
+
audioElements,
|
|
321
|
+
selectPreviousEnabledLine,
|
|
322
|
+
selectNextEnabledLine,
|
|
323
|
+
playCurrentLine,
|
|
324
|
+
adjustStartTime,
|
|
325
|
+
adjustEndTime,
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
// Search for songs
|
|
329
|
+
const handleSearch = async (term) => {
|
|
330
|
+
setSearchTerm(term);
|
|
331
|
+
|
|
332
|
+
if (!term.trim()) {
|
|
333
|
+
setSearchResults([]);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
setIsSearching(true);
|
|
339
|
+
const result = await bridge.searchSongs(term);
|
|
340
|
+
setSearchResults(result.songs || []);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
console.error('Search failed:', error);
|
|
343
|
+
} finally {
|
|
344
|
+
setIsSearching(false);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Load a song for editing
|
|
349
|
+
const handleLoadSong = async (song) => {
|
|
350
|
+
try {
|
|
351
|
+
console.log('🔍 Loading song for editing:', song.path);
|
|
352
|
+
const result = await bridge.loadSongForEditing(song.path);
|
|
353
|
+
|
|
354
|
+
if (result.success) {
|
|
355
|
+
setLoadedSong(song);
|
|
356
|
+
setSongData(result.data);
|
|
357
|
+
|
|
358
|
+
// Populate metadata form
|
|
359
|
+
setMetadata({
|
|
360
|
+
title: result.data.metadata?.title || song.title || '',
|
|
361
|
+
artist: result.data.metadata?.artist || song.artist || '',
|
|
362
|
+
album: result.data.metadata?.album || song.album || '',
|
|
363
|
+
year: result.data.metadata?.year || song.year || '',
|
|
364
|
+
genre: result.data.metadata?.genre || song.genre || '',
|
|
365
|
+
key: result.data.metadata?.key || song.key || '',
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Populate lyrics if KAI or M4A file - server sends actual array with timing
|
|
369
|
+
const hasLyrics = result.data.format === 'kai' || result.data.format === 'm4a-stems';
|
|
370
|
+
if (hasLyrics) {
|
|
371
|
+
const lyrics = result.data.lyrics || [];
|
|
372
|
+
// Sort lyrics by start time to ensure proper order
|
|
373
|
+
const sortedLyrics = [...lyrics].sort((a, b) => {
|
|
374
|
+
const aStart = a.start || a.startTimeSec || 0;
|
|
375
|
+
const bStart = b.start || b.startTimeSec || 0;
|
|
376
|
+
return aStart - bStart;
|
|
377
|
+
});
|
|
378
|
+
setLyricsData(JSON.parse(JSON.stringify(sortedLyrics)));
|
|
379
|
+
setOriginalLyricsData(JSON.parse(JSON.stringify(sortedLyrics)));
|
|
380
|
+
setSongDuration(
|
|
381
|
+
result.data.metadata?.duration || result.data.songJson?.duration_sec || 0
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Load AI corrections if available
|
|
385
|
+
// Check both 'rejected' (user-rejected) and 'applied' (LLM-applied) for compatibility
|
|
386
|
+
const corrections = result.data.songJson?.meta?.corrections || {};
|
|
387
|
+
const kaiRejections = corrections.rejected || corrections.applied || [];
|
|
388
|
+
setRejections(
|
|
389
|
+
kaiRejections.map((rejection) => ({
|
|
390
|
+
line_num: rejection.line,
|
|
391
|
+
start_time: rejection.start,
|
|
392
|
+
end_time: rejection.end,
|
|
393
|
+
old_text: rejection.old,
|
|
394
|
+
new_text: rejection.new,
|
|
395
|
+
reason: rejection.reason,
|
|
396
|
+
retention_rate: rejection.word_retention,
|
|
397
|
+
min_required: 0.5,
|
|
398
|
+
}))
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const kaiSuggestions = corrections.missing_lines_suggested || [];
|
|
402
|
+
setSuggestions(
|
|
403
|
+
kaiSuggestions.map((suggestion) => ({
|
|
404
|
+
suggested_text: suggestion.suggested_text,
|
|
405
|
+
start_time: suggestion.start,
|
|
406
|
+
end_time: suggestion.end,
|
|
407
|
+
confidence: suggestion.confidence,
|
|
408
|
+
reason: suggestion.reason,
|
|
409
|
+
pitch_activity: suggestion.pitch_activity,
|
|
410
|
+
}))
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
setHasChanges(false);
|
|
414
|
+
} else {
|
|
415
|
+
setLyricsData([]);
|
|
416
|
+
setOriginalLyricsData([]);
|
|
417
|
+
setSongDuration(0);
|
|
418
|
+
setRejections([]);
|
|
419
|
+
setSuggestions([]);
|
|
420
|
+
setHasChanges(false);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Clear search
|
|
424
|
+
setSearchResults([]);
|
|
425
|
+
setSearchTerm('');
|
|
426
|
+
|
|
427
|
+
// Default to lyrics tab for KAI/M4A files, metadata for others
|
|
428
|
+
setActiveTab(hasLyrics ? 'lyrics' : 'metadata');
|
|
429
|
+
|
|
430
|
+
// Load audio if KAI or M4A file
|
|
431
|
+
if (hasLyrics && result.data.audioFiles) {
|
|
432
|
+
loadAudioFiles(result.data.audioFiles);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.error('Failed to load song:', error);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Load audio files for KAI and M4A songs
|
|
441
|
+
const loadAudioFiles = (audioFiles) => {
|
|
442
|
+
try {
|
|
443
|
+
// Clean up existing audio
|
|
444
|
+
cleanupAudio();
|
|
445
|
+
|
|
446
|
+
// Create new AudioContext (using default device)
|
|
447
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
448
|
+
setAudioContext(ctx);
|
|
449
|
+
|
|
450
|
+
// Create audio elements for each source
|
|
451
|
+
const elements = audioFiles.map((file) => {
|
|
452
|
+
const audio = new Audio(file.downloadUrl);
|
|
453
|
+
audio.crossOrigin = 'anonymous'; // For CORS if needed
|
|
454
|
+
audio.preload = 'auto';
|
|
455
|
+
audio.volume = 1.0;
|
|
456
|
+
|
|
457
|
+
// Create media element source for the audio context
|
|
458
|
+
const source = ctx.createMediaElementSource(audio);
|
|
459
|
+
const gainNode = ctx.createGain();
|
|
460
|
+
|
|
461
|
+
source.connect(gainNode);
|
|
462
|
+
gainNode.connect(ctx.destination);
|
|
463
|
+
|
|
464
|
+
// Only vocals unmuted by default
|
|
465
|
+
const isVocals = file.name.toLowerCase().includes('vocal');
|
|
466
|
+
const muted = !isVocals;
|
|
467
|
+
gainNode.gain.value = muted ? 0 : 1;
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
name: file.name,
|
|
471
|
+
audio: audio,
|
|
472
|
+
source: source,
|
|
473
|
+
gainNode: gainNode,
|
|
474
|
+
muted: muted,
|
|
475
|
+
audioData: file.audioData, // Keep reference to raw audio data (Electron only)
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
setAudioElements(elements);
|
|
480
|
+
console.log(`🎵 Loaded ${elements.length} audio sources for playback`);
|
|
481
|
+
|
|
482
|
+
// Find vocals track and analyze waveform
|
|
483
|
+
const vocalsFile = audioFiles.find((file) => file.name.toLowerCase().includes('vocal'));
|
|
484
|
+
const vocalsElement = elements.find((el) => el.name.toLowerCase().includes('vocal'));
|
|
485
|
+
|
|
486
|
+
if (vocalsElement) {
|
|
487
|
+
setupAudioPlaybackMonitoring(vocalsElement.audio);
|
|
488
|
+
// Pass both audio element and raw data (if available)
|
|
489
|
+
analyzeVocalsWaveform(vocalsElement.audio, vocalsFile?.audioData);
|
|
490
|
+
} else if (elements[0]) {
|
|
491
|
+
// Fallback to first track if no vocals
|
|
492
|
+
setupAudioPlaybackMonitoring(elements[0].audio);
|
|
493
|
+
analyzeVocalsWaveform(elements[0].audio, audioFiles[0]?.audioData);
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
console.error('Failed to load audio files:', error);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// Setup audio playback monitoring for playhead
|
|
501
|
+
const setupAudioPlaybackMonitoring = (audio) => {
|
|
502
|
+
const handlePause = () => {
|
|
503
|
+
setPlayingLineEndTime(null);
|
|
504
|
+
setIsPlaying(false);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
audio.addEventListener('pause', handlePause);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Smooth playhead animation using requestAnimationFrame
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
if (isPlaying && audioElements.length > 0) {
|
|
513
|
+
const updatePosition = () => {
|
|
514
|
+
const audio = audioElements[0]?.audio;
|
|
515
|
+
if (audio && !audio.paused) {
|
|
516
|
+
setCurrentPosition(audio.currentTime);
|
|
517
|
+
animationFrameRef.current = requestAnimationFrame(updatePosition);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
animationFrameRef.current = requestAnimationFrame(updatePosition);
|
|
522
|
+
|
|
523
|
+
return () => {
|
|
524
|
+
if (animationFrameRef.current) {
|
|
525
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}, [isPlaying, audioElements]);
|
|
530
|
+
|
|
531
|
+
// Check if playback should stop at line end time
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
if (playingLineEndTime !== null && currentPosition >= playingLineEndTime) {
|
|
534
|
+
// Pause all audio elements
|
|
535
|
+
audioElements.forEach(({ audio }) => audio.pause());
|
|
536
|
+
setPlayingLineEndTime(null);
|
|
537
|
+
setIsPlaying(false);
|
|
538
|
+
}
|
|
539
|
+
}, [currentPosition, playingLineEndTime, audioElements]);
|
|
540
|
+
|
|
541
|
+
// Analyze vocals waveform
|
|
542
|
+
const analyzeVocalsWaveform = async (audioElement, rawAudioData) => {
|
|
543
|
+
try {
|
|
544
|
+
let arrayBuffer;
|
|
545
|
+
|
|
546
|
+
// Try to use raw audio data first (Electron with Buffer data)
|
|
547
|
+
if (rawAudioData) {
|
|
548
|
+
if (rawAudioData instanceof ArrayBuffer) {
|
|
549
|
+
arrayBuffer = rawAudioData;
|
|
550
|
+
} else if (rawAudioData.buffer instanceof ArrayBuffer) {
|
|
551
|
+
// It's a typed array (like Uint8Array or Buffer)
|
|
552
|
+
arrayBuffer = rawAudioData.buffer.slice(
|
|
553
|
+
rawAudioData.byteOffset,
|
|
554
|
+
rawAudioData.byteOffset + rawAudioData.byteLength
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Fall back to fetching from audio element src (Web with blob URLs)
|
|
560
|
+
if (!arrayBuffer && audioElement?.src) {
|
|
561
|
+
const response = await fetch(audioElement.src);
|
|
562
|
+
arrayBuffer = await response.arrayBuffer();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (!arrayBuffer) {
|
|
566
|
+
throw new Error('No audio data available for waveform analysis');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Create temporary audio context for analysis
|
|
570
|
+
const tempContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
571
|
+
const audioBuffer = await tempContext.decodeAudioData(arrayBuffer);
|
|
572
|
+
|
|
573
|
+
// Get channel data
|
|
574
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
575
|
+
const targetSamples = 3800;
|
|
576
|
+
const downsampleFactor = Math.floor(channelData.length / targetSamples);
|
|
577
|
+
|
|
578
|
+
// Create waveform data
|
|
579
|
+
const waveform = new Int8Array(targetSamples);
|
|
580
|
+
|
|
581
|
+
for (let i = 0; i < targetSamples; i++) {
|
|
582
|
+
const start = i * downsampleFactor;
|
|
583
|
+
const end = Math.min(start + downsampleFactor, channelData.length);
|
|
584
|
+
|
|
585
|
+
let max = 0;
|
|
586
|
+
for (let j = start; j < end; j++) {
|
|
587
|
+
max = Math.max(max, Math.abs(channelData[j]));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
waveform[i] = Math.floor(max * 127);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
setVocalsWaveform(waveform);
|
|
594
|
+
tempContext.close();
|
|
595
|
+
|
|
596
|
+
console.log('✅ Waveform analysis complete');
|
|
597
|
+
} catch (error) {
|
|
598
|
+
console.error('Failed to analyze waveform:', error);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Play/pause audio
|
|
603
|
+
const togglePlayback = () => {
|
|
604
|
+
if (!audioElements.length) return;
|
|
605
|
+
|
|
606
|
+
if (isPlaying) {
|
|
607
|
+
audioElements.forEach(({ audio }) => audio.pause());
|
|
608
|
+
setIsPlaying(false);
|
|
609
|
+
} else {
|
|
610
|
+
audioElements.forEach(({ audio }) => audio.play());
|
|
611
|
+
setIsPlaying(true);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// Toggle mute for individual source
|
|
616
|
+
const toggleMute = (index) => {
|
|
617
|
+
setAudioElements((prev) =>
|
|
618
|
+
prev.map((el, i) => {
|
|
619
|
+
if (i === index) {
|
|
620
|
+
const newMuted = !el.muted;
|
|
621
|
+
el.gainNode.gain.value = newMuted ? 0 : 1;
|
|
622
|
+
return { ...el, muted: newMuted };
|
|
623
|
+
}
|
|
624
|
+
return el;
|
|
625
|
+
})
|
|
626
|
+
);
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// Cleanup on component unmount only (not when cleanupAudio changes)
|
|
630
|
+
useEffect(() => {
|
|
631
|
+
return () => {
|
|
632
|
+
// Direct cleanup without using the callback (to avoid dependency issues)
|
|
633
|
+
audioElements.forEach(({ audio, source }) => {
|
|
634
|
+
audio.pause();
|
|
635
|
+
audio.currentTime = 0;
|
|
636
|
+
try {
|
|
637
|
+
source.disconnect();
|
|
638
|
+
} catch {
|
|
639
|
+
// Ignore disconnect errors
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (audioContext) {
|
|
644
|
+
audioContext.close();
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
648
|
+
}, []); // Only run on unmount
|
|
649
|
+
|
|
650
|
+
// Show toast notification
|
|
651
|
+
const showToast = (message, type = 'success') => {
|
|
652
|
+
setToast({ message, type });
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const handleLineDelete = (index) => {
|
|
656
|
+
setLyricsData((prev) => prev.filter((_, i) => i !== index));
|
|
657
|
+
setSelectedLineIndex(null);
|
|
658
|
+
setHasChanges(true);
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const handleAddLineAfter = (index) => {
|
|
662
|
+
const currentLine = lyricsData[index];
|
|
663
|
+
const nextLine = lyricsData[index + 1];
|
|
664
|
+
|
|
665
|
+
const currentEndTime = currentLine.end || currentLine.endTimeSec || 0;
|
|
666
|
+
const nextStartTime = nextLine
|
|
667
|
+
? nextLine.start || nextLine.startTimeSec || currentEndTime + 3
|
|
668
|
+
: currentEndTime + 3;
|
|
669
|
+
|
|
670
|
+
const gap = nextStartTime - currentEndTime;
|
|
671
|
+
const usableGap = gap * 0.8;
|
|
672
|
+
const margin = gap * 0.1;
|
|
673
|
+
|
|
674
|
+
const newLine = {
|
|
675
|
+
start: currentEndTime + margin,
|
|
676
|
+
startTimeSec: currentEndTime + margin,
|
|
677
|
+
end: currentEndTime + margin + usableGap,
|
|
678
|
+
endTimeSec: currentEndTime + margin + usableGap,
|
|
679
|
+
text: '',
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
setLyricsData((prev) => [...prev.slice(0, index + 1), newLine, ...prev.slice(index + 1)]);
|
|
683
|
+
setHasChanges(true);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const handleLineSplit = (index) => {
|
|
687
|
+
const line = lyricsData[index];
|
|
688
|
+
|
|
689
|
+
// Try to split the line
|
|
690
|
+
const splitResult = splitLine(line, index);
|
|
691
|
+
|
|
692
|
+
if (!splitResult) {
|
|
693
|
+
// Show toast if split failed
|
|
694
|
+
showToast('Cannot split line: no punctuation found or would create empty line', 'error');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const [firstLine, secondLine] = splitResult;
|
|
699
|
+
|
|
700
|
+
// Replace the line at index with the two new lines
|
|
701
|
+
setLyricsData((prev) => [
|
|
702
|
+
...prev.slice(0, index),
|
|
703
|
+
firstLine,
|
|
704
|
+
secondLine,
|
|
705
|
+
...prev.slice(index + 1),
|
|
706
|
+
]);
|
|
707
|
+
|
|
708
|
+
// Select the second line
|
|
709
|
+
setSelectedLineIndex(index + 1);
|
|
710
|
+
setHasChanges(true);
|
|
711
|
+
showToast('Line split successfully', 'success');
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const handleAddLineAtStart = () => {
|
|
715
|
+
const firstLine = lyricsData[0];
|
|
716
|
+
if (!firstLine) {
|
|
717
|
+
// No lines exist, create a default one
|
|
718
|
+
const newLine = {
|
|
719
|
+
start: 0,
|
|
720
|
+
startTimeSec: 0,
|
|
721
|
+
end: 3,
|
|
722
|
+
endTimeSec: 3,
|
|
723
|
+
text: '',
|
|
724
|
+
};
|
|
725
|
+
setLyricsData([newLine]);
|
|
726
|
+
setHasChanges(true);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const firstLineStart = firstLine.start || firstLine.startTimeSec || 0;
|
|
731
|
+
|
|
732
|
+
// Create a line from 0 to 80% of available space
|
|
733
|
+
const gap = firstLineStart;
|
|
734
|
+
const usableGap = gap * 0.8;
|
|
735
|
+
|
|
736
|
+
const newLine = {
|
|
737
|
+
start: 0,
|
|
738
|
+
startTimeSec: 0,
|
|
739
|
+
end: usableGap,
|
|
740
|
+
endTimeSec: usableGap,
|
|
741
|
+
text: '',
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
setLyricsData((prev) => [newLine, ...prev]);
|
|
745
|
+
setHasChanges(true);
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const canAddLineAtStart = () => {
|
|
749
|
+
const firstLine = lyricsData[0];
|
|
750
|
+
if (!firstLine) return true;
|
|
751
|
+
const firstLineStart = firstLine.start || firstLine.startTimeSec || 0;
|
|
752
|
+
return firstLineStart >= 0.6;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const canAddLineAfter = (index) => {
|
|
756
|
+
const currentLine = lyricsData[index];
|
|
757
|
+
const nextLine = lyricsData[index + 1];
|
|
758
|
+
|
|
759
|
+
if (!nextLine) return true;
|
|
760
|
+
|
|
761
|
+
const currentEndTime = currentLine.end || currentLine.endTimeSec || 0;
|
|
762
|
+
const nextStartTime = nextLine.start || nextLine.startTimeSec || 0;
|
|
763
|
+
const gap = nextStartTime - currentEndTime;
|
|
764
|
+
|
|
765
|
+
return gap >= 0.6;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const canSplit = (index) => {
|
|
769
|
+
if (index < 0 || index >= lyricsData.length) return false;
|
|
770
|
+
return canSplitLine(lyricsData[index]);
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// Handle metadata field changes
|
|
774
|
+
const handleMetadataChange = (field, value) => {
|
|
775
|
+
setMetadata((prev) => ({
|
|
776
|
+
...prev,
|
|
777
|
+
[field]: value,
|
|
778
|
+
}));
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
// Save changes
|
|
782
|
+
const handleSave = async () => {
|
|
783
|
+
if (!loadedSong || !songData) return;
|
|
784
|
+
|
|
785
|
+
try {
|
|
786
|
+
setIsSaving(true);
|
|
787
|
+
|
|
788
|
+
// Sort lyrics by start time before saving
|
|
789
|
+
const sortedLyrics = [...lyricsData].sort((a, b) => {
|
|
790
|
+
const aStart = a.start || a.startTimeSec || 0;
|
|
791
|
+
const bStart = b.start || b.startTimeSec || 0;
|
|
792
|
+
return aStart - bStart;
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const updates = {
|
|
796
|
+
path: loadedSong.path,
|
|
797
|
+
format: songData.format,
|
|
798
|
+
metadata: {
|
|
799
|
+
...metadata,
|
|
800
|
+
// Include rejections and suggestions so they can be saved to meta object
|
|
801
|
+
rejections,
|
|
802
|
+
suggestions,
|
|
803
|
+
},
|
|
804
|
+
// Include lyrics for both KAI and M4A formats
|
|
805
|
+
...((songData.format === 'kai' || songData.format === 'm4a-stems') && {
|
|
806
|
+
lyrics: sortedLyrics,
|
|
807
|
+
}),
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const result = await bridge.saveSongEdits(updates);
|
|
811
|
+
|
|
812
|
+
if (result.success) {
|
|
813
|
+
console.log('✅ Song saved successfully');
|
|
814
|
+
showToast('Song saved successfully', 'success');
|
|
815
|
+
setHasChanges(false);
|
|
816
|
+
// Update original data after successful save
|
|
817
|
+
setOriginalLyricsData(JSON.parse(JSON.stringify(lyricsData)));
|
|
818
|
+
} else {
|
|
819
|
+
console.error('❌ Save failed:', result.error);
|
|
820
|
+
showToast(`Save failed: ${result.error}`, 'error');
|
|
821
|
+
}
|
|
822
|
+
} catch (error) {
|
|
823
|
+
console.error('Failed to save song:', error);
|
|
824
|
+
showToast(`Save failed: ${error.message}`, 'error');
|
|
825
|
+
} finally {
|
|
826
|
+
setIsSaving(false);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// Close editor
|
|
831
|
+
const handleClose = () => {
|
|
832
|
+
setLoadedSong(null);
|
|
833
|
+
setSongData(null);
|
|
834
|
+
setMetadata({
|
|
835
|
+
title: '',
|
|
836
|
+
artist: '',
|
|
837
|
+
album: '',
|
|
838
|
+
year: '',
|
|
839
|
+
genre: '',
|
|
840
|
+
key: '',
|
|
841
|
+
});
|
|
842
|
+
setLyricsData([]);
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
// Add to queue
|
|
846
|
+
const handleAddToQueue = async () => {
|
|
847
|
+
if (!loadedSong) return;
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
await bridge.addToQueue(loadedSong);
|
|
851
|
+
console.log('✅ Added to queue:', loadedSong.path);
|
|
852
|
+
showToast(`Added "${metadata.title || loadedSong.title}" to queue`, 'success');
|
|
853
|
+
} catch (error) {
|
|
854
|
+
console.error('Failed to add to queue:', error);
|
|
855
|
+
showToast('Failed to add to queue', 'error');
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// Export lyrics as text file
|
|
860
|
+
const handleExportLyrics = () => {
|
|
861
|
+
if (!lyricsData || lyricsData.length === 0) return;
|
|
862
|
+
|
|
863
|
+
const lyricsText = lyricsData.map((line) => line.text || '').join('\n');
|
|
864
|
+
const blob = new Blob([lyricsText], { type: 'text/plain' });
|
|
865
|
+
const url = URL.createObjectURL(blob);
|
|
866
|
+
|
|
867
|
+
const a = document.createElement('a');
|
|
868
|
+
a.href = url;
|
|
869
|
+
a.download = `${metadata.title || loadedSong?.title || 'lyrics'}.txt`;
|
|
870
|
+
document.body.appendChild(a);
|
|
871
|
+
a.click();
|
|
872
|
+
document.body.removeChild(a);
|
|
873
|
+
|
|
874
|
+
URL.revokeObjectURL(url);
|
|
875
|
+
showToast('Lyrics exported successfully', 'success');
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// Reset to original lyrics
|
|
879
|
+
const handleResetLyrics = () => {
|
|
880
|
+
if (!confirm('Reset all changes to original lyrics?')) return;
|
|
881
|
+
|
|
882
|
+
setLyricsData(JSON.parse(JSON.stringify(originalLyricsData)));
|
|
883
|
+
setHasChanges(false);
|
|
884
|
+
showToast('Reset to original lyrics', 'success');
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// Handle rejection acceptance
|
|
888
|
+
const handleAcceptRejection = (rejectionIndex) => {
|
|
889
|
+
const rejection = rejections[rejectionIndex];
|
|
890
|
+
if (!rejection) return;
|
|
891
|
+
|
|
892
|
+
// Find the lyric line to update by matching timing
|
|
893
|
+
let targetLineIndex = -1;
|
|
894
|
+
|
|
895
|
+
for (let i = 0; i < lyricsData.length; i++) {
|
|
896
|
+
const line = lyricsData[i];
|
|
897
|
+
const lineStart = line.start || line.startTimeSec || 0;
|
|
898
|
+
const lineEnd = line.end || line.endTimeSec || 0;
|
|
899
|
+
|
|
900
|
+
// Match by timing
|
|
901
|
+
if (rejection.start_time !== undefined && rejection.end_time !== undefined) {
|
|
902
|
+
if (
|
|
903
|
+
Math.abs(lineStart - rejection.start_time) < 0.1 &&
|
|
904
|
+
Math.abs(lineEnd - rejection.end_time) < 0.1
|
|
905
|
+
) {
|
|
906
|
+
targetLineIndex = i;
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
} else if (rejection.old_text && line.text === rejection.old_text) {
|
|
910
|
+
// Fallback: match by old text content
|
|
911
|
+
targetLineIndex = i;
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// If no timing match found, fallback to line number approach
|
|
917
|
+
if (targetLineIndex === -1) {
|
|
918
|
+
const lineIndex = rejection.line_num - 1;
|
|
919
|
+
if (lineIndex >= 0 && lineIndex < lyricsData.length) {
|
|
920
|
+
targetLineIndex = lineIndex;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (targetLineIndex >= 0 && targetLineIndex < lyricsData.length) {
|
|
925
|
+
// Update the lyric text with the proposed text
|
|
926
|
+
const updatedLine = { ...lyricsData[targetLineIndex], text: rejection.new_text };
|
|
927
|
+
setLyricsData((prev) => prev.map((line, i) => (i === targetLineIndex ? updatedLine : line)));
|
|
928
|
+
|
|
929
|
+
// Remove the rejection from the list
|
|
930
|
+
setRejections((prev) => prev.filter((_, i) => i !== rejectionIndex));
|
|
931
|
+
setHasChanges(true);
|
|
932
|
+
showToast('Accepted proposed text', 'success');
|
|
933
|
+
} else {
|
|
934
|
+
showToast('Could not find matching lyric line', 'error');
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// Handle rejection deletion
|
|
939
|
+
const handleDeleteRejection = (rejectionIndex) => {
|
|
940
|
+
setRejections((prev) => prev.filter((_, i) => i !== rejectionIndex));
|
|
941
|
+
setHasChanges(true);
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// Handle suggestion acceptance
|
|
945
|
+
const handleAcceptSuggestion = (suggestionIndex) => {
|
|
946
|
+
const suggestion = suggestions[suggestionIndex];
|
|
947
|
+
if (!suggestion) return;
|
|
948
|
+
|
|
949
|
+
// Find the best insertion point based on timing
|
|
950
|
+
let insertionIndex = lyricsData.length; // Default to end
|
|
951
|
+
|
|
952
|
+
for (let i = 0; i < lyricsData.length; i++) {
|
|
953
|
+
const line = lyricsData[i];
|
|
954
|
+
const lineStart = line.start || line.startTimeSec || 0;
|
|
955
|
+
|
|
956
|
+
if (suggestion.start_time < lineStart) {
|
|
957
|
+
insertionIndex = i;
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Create new lyric line from suggestion
|
|
963
|
+
const newLine = {
|
|
964
|
+
start: suggestion.start_time,
|
|
965
|
+
startTimeSec: suggestion.start_time,
|
|
966
|
+
end: suggestion.end_time,
|
|
967
|
+
endTimeSec: suggestion.end_time,
|
|
968
|
+
text: suggestion.suggested_text,
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
// Insert the new line at the correct position
|
|
972
|
+
setLyricsData((prev) => [
|
|
973
|
+
...prev.slice(0, insertionIndex),
|
|
974
|
+
newLine,
|
|
975
|
+
...prev.slice(insertionIndex),
|
|
976
|
+
]);
|
|
977
|
+
|
|
978
|
+
// Remove the suggestion from the list
|
|
979
|
+
setSuggestions((prev) => prev.filter((_, i) => i !== suggestionIndex));
|
|
980
|
+
setHasChanges(true);
|
|
981
|
+
showToast('Added suggested line', 'success');
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// Handle suggestion deletion
|
|
985
|
+
const handleDeleteSuggestion = (suggestionIndex) => {
|
|
986
|
+
setSuggestions((prev) => prev.filter((_, i) => i !== suggestionIndex));
|
|
987
|
+
setHasChanges(true);
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
return (
|
|
991
|
+
<div className="flex flex-col h-full overflow-hidden bg-gray-50 dark:bg-gray-900 p-4">
|
|
992
|
+
{!loadedSong ? (
|
|
993
|
+
// Search view
|
|
994
|
+
<div className="flex flex-col gap-6 max-w-[800px] mx-auto w-full">
|
|
995
|
+
<div className="relative flex items-center">
|
|
996
|
+
<input
|
|
997
|
+
type="text"
|
|
998
|
+
className="w-full px-5 py-4 bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-base transition-colors focus:outline-none focus:border-blue-600"
|
|
999
|
+
placeholder="Search by title, artist, or album..."
|
|
1000
|
+
value={searchTerm}
|
|
1001
|
+
onChange={(e) => handleSearch(e.target.value)}
|
|
1002
|
+
/>
|
|
1003
|
+
{isSearching && <span className="absolute right-5 text-xl animate-spin">🔍</span>}
|
|
1004
|
+
|
|
1005
|
+
{searchResults.length > 0 && (
|
|
1006
|
+
<div className="absolute top-full left-0 right-0 max-h-[400px] overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg z-[100] mt-1">
|
|
1007
|
+
{searchResults.map((song, index) => (
|
|
1008
|
+
<div
|
|
1009
|
+
key={index}
|
|
1010
|
+
className="flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-gray-200 dark:border-gray-700 last:border-b-0 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
1011
|
+
onClick={() => handleLoadSong(song)}
|
|
1012
|
+
>
|
|
1013
|
+
<span className="text-xl flex-shrink-0">{getFormatIcon(song.format)}</span>
|
|
1014
|
+
<div className="flex-1 min-w-0">
|
|
1015
|
+
<div className="text-[15px] font-semibold text-gray-900 dark:text-white mb-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
|
|
1016
|
+
{song.title}
|
|
1017
|
+
</div>
|
|
1018
|
+
<div className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap overflow-hidden text-ellipsis">
|
|
1019
|
+
{song.artist} {song.album && `• ${song.album}`}
|
|
1020
|
+
</div>
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
))}
|
|
1024
|
+
</div>
|
|
1025
|
+
)}
|
|
1026
|
+
</div>
|
|
1027
|
+
|
|
1028
|
+
{searchTerm && !isSearching && searchResults.length === 0 && (
|
|
1029
|
+
<div className="flex flex-col items-center justify-center p-16 text-center">
|
|
1030
|
+
<div className="text-6xl mb-4 opacity-50">🔍</div>
|
|
1031
|
+
<div className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
|
1032
|
+
No songs found
|
|
1033
|
+
</div>
|
|
1034
|
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
1035
|
+
Try a different search term
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
)}
|
|
1039
|
+
|
|
1040
|
+
{!searchTerm && (
|
|
1041
|
+
<div className="flex flex-col items-center justify-center p-16 text-center">
|
|
1042
|
+
<div className="text-6xl mb-4 opacity-50">🎵</div>
|
|
1043
|
+
<div className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
|
1044
|
+
Search for a song to get started
|
|
1045
|
+
</div>
|
|
1046
|
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
1047
|
+
You can edit metadata and lyrics for any song in your library
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>
|
|
1050
|
+
)}
|
|
1051
|
+
</div>
|
|
1052
|
+
) : (
|
|
1053
|
+
// Edit view
|
|
1054
|
+
<div className="flex flex-col gap-3 h-full overflow-hidden">
|
|
1055
|
+
<div className="flex items-center justify-between gap-4 flex-shrink-0">
|
|
1056
|
+
<div className="flex-1 min-w-0">
|
|
1057
|
+
<h2 className="text-lg font-semibold m-0 text-gray-900 dark:text-white whitespace-nowrap overflow-hidden text-ellipsis">
|
|
1058
|
+
{loadedSong.title}
|
|
1059
|
+
</h2>
|
|
1060
|
+
<p className="text-xs text-gray-600 dark:text-gray-400 m-0 mt-0.5">
|
|
1061
|
+
{loadedSong.artist} • {songData.format?.toUpperCase()}
|
|
1062
|
+
</p>
|
|
1063
|
+
</div>
|
|
1064
|
+
<div className="flex gap-2 flex-shrink-0">
|
|
1065
|
+
<button
|
|
1066
|
+
onClick={handleAddToQueue}
|
|
1067
|
+
className="flex items-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white cursor-pointer text-sm transition-colors hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
1068
|
+
>
|
|
1069
|
+
<span className="material-icons text-lg">playlist_add</span>
|
|
1070
|
+
Add to Queue
|
|
1071
|
+
</button>
|
|
1072
|
+
<button
|
|
1073
|
+
onClick={handleClose}
|
|
1074
|
+
className="flex items-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white cursor-pointer text-sm transition-colors hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
1075
|
+
>
|
|
1076
|
+
<span className="material-icons text-lg">close</span>
|
|
1077
|
+
Close
|
|
1078
|
+
</button>
|
|
1079
|
+
<button
|
|
1080
|
+
onClick={handleSave}
|
|
1081
|
+
className={`flex items-center gap-1.5 px-3 py-2 rounded text-white cursor-pointer text-sm transition-colors ${hasChanges ? 'bg-blue-600 border-blue-600 hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600'} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
1082
|
+
disabled={isSaving}
|
|
1083
|
+
>
|
|
1084
|
+
<span className="material-icons text-lg">save</span>
|
|
1085
|
+
{isSaving ? 'Saving...' : hasChanges ? 'Save*' : 'Save'}
|
|
1086
|
+
</button>
|
|
1087
|
+
</div>
|
|
1088
|
+
</div>
|
|
1089
|
+
|
|
1090
|
+
{/* Tab navigation for KAI and M4A files */}
|
|
1091
|
+
{(songData.format === 'kai' || songData.format === 'm4a-stems') && (
|
|
1092
|
+
<div className="flex gap-1 border-b-2 border-gray-200 dark:border-gray-700 pb-0">
|
|
1093
|
+
<button
|
|
1094
|
+
className={`px-6 py-3 bg-transparent border-none border-b-[3px] font-semibold text-[15px] cursor-pointer transition-all -mb-0.5 ${activeTab === 'lyrics' ? 'text-blue-600 border-b-blue-600' : 'text-gray-600 dark:text-gray-400 border-b-transparent hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700'}`}
|
|
1095
|
+
onClick={() => setActiveTab('lyrics')}
|
|
1096
|
+
>
|
|
1097
|
+
Lyrics
|
|
1098
|
+
</button>
|
|
1099
|
+
<button
|
|
1100
|
+
className={`px-6 py-3 bg-transparent border-none border-b-[3px] font-semibold text-[15px] cursor-pointer transition-all -mb-0.5 ${activeTab === 'metadata' ? 'text-blue-600 border-b-blue-600' : 'text-gray-600 dark:text-gray-400 border-b-transparent hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700'}`}
|
|
1101
|
+
onClick={() => setActiveTab('metadata')}
|
|
1102
|
+
>
|
|
1103
|
+
Metadata
|
|
1104
|
+
</button>
|
|
1105
|
+
</div>
|
|
1106
|
+
)}
|
|
1107
|
+
|
|
1108
|
+
{/* Metadata form */}
|
|
1109
|
+
{(activeTab === 'metadata' ||
|
|
1110
|
+
(songData.format !== 'kai' && songData.format !== 'm4a-stems')) && (
|
|
1111
|
+
<div className="flex flex-col gap-6 overflow-y-auto flex-1 pb-6">
|
|
1112
|
+
<h3 className="text-lg font-semibold m-0 text-gray-900 dark:text-white">Metadata</h3>
|
|
1113
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-5">
|
|
1114
|
+
<div className="flex flex-col gap-2">
|
|
1115
|
+
<label className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
|
1116
|
+
Title
|
|
1117
|
+
</label>
|
|
1118
|
+
<input
|
|
1119
|
+
type="text"
|
|
1120
|
+
className="px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white text-[15px] transition-colors focus:outline-none focus:border-blue-600"
|
|
1121
|
+
value={metadata.title}
|
|
1122
|
+
onChange={(e) => handleMetadataChange('title', e.target.value)}
|
|
1123
|
+
/>
|
|
1124
|
+
</div>
|
|
1125
|
+
<div className="flex flex-col gap-2">
|
|
1126
|
+
<label className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
|
1127
|
+
Artist
|
|
1128
|
+
</label>
|
|
1129
|
+
<input
|
|
1130
|
+
type="text"
|
|
1131
|
+
className="px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white text-[15px] transition-colors focus:outline-none focus:border-blue-600"
|
|
1132
|
+
value={metadata.artist}
|
|
1133
|
+
onChange={(e) => handleMetadataChange('artist', e.target.value)}
|
|
1134
|
+
/>
|
|
1135
|
+
</div>
|
|
1136
|
+
<div className="flex flex-col gap-2">
|
|
1137
|
+
<label className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
|
1138
|
+
Album
|
|
1139
|
+
</label>
|
|
1140
|
+
<input
|
|
1141
|
+
type="text"
|
|
1142
|
+
className="px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white text-[15px] transition-colors focus:outline-none focus:border-blue-600"
|
|
1143
|
+
value={metadata.album}
|
|
1144
|
+
onChange={(e) => handleMetadataChange('album', e.target.value)}
|
|
1145
|
+
/>
|
|
1146
|
+
</div>
|
|
1147
|
+
<div className="flex flex-col gap-2">
|
|
1148
|
+
<label className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
|
1149
|
+
Year
|
|
1150
|
+
</label>
|
|
1151
|
+
<input
|
|
1152
|
+
type="text"
|
|
1153
|
+
className="px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white text-[15px] transition-colors focus:outline-none focus:border-blue-600"
|
|
1154
|
+
value={metadata.year}
|
|
1155
|
+
onChange={(e) => handleMetadataChange('year', e.target.value)}
|
|
1156
|
+
/>
|
|
1157
|
+
</div>
|
|
1158
|
+
<div className="flex flex-col gap-2">
|
|
1159
|
+
<label className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
|
1160
|
+
Genre
|
|
1161
|
+
</label>
|
|
1162
|
+
<input
|
|
1163
|
+
type="text"
|
|
1164
|
+
className="px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white text-[15px] transition-colors focus:outline-none focus:border-blue-600"
|
|
1165
|
+
value={metadata.genre}
|
|
1166
|
+
onChange={(e) => handleMetadataChange('genre', e.target.value)}
|
|
1167
|
+
/>
|
|
1168
|
+
</div>
|
|
1169
|
+
<div className="flex flex-col gap-2">
|
|
1170
|
+
<label className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
|
1171
|
+
Key
|
|
1172
|
+
</label>
|
|
1173
|
+
<input
|
|
1174
|
+
type="text"
|
|
1175
|
+
className="px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white text-[15px] transition-colors focus:outline-none focus:border-blue-600"
|
|
1176
|
+
value={metadata.key}
|
|
1177
|
+
onChange={(e) => handleMetadataChange('key', e.target.value)}
|
|
1178
|
+
/>
|
|
1179
|
+
</div>
|
|
1180
|
+
</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
)}
|
|
1183
|
+
|
|
1184
|
+
{/* Lyrics editor for KAI and M4A files */}
|
|
1185
|
+
{(songData.format === 'kai' || songData.format === 'm4a-stems') &&
|
|
1186
|
+
activeTab === 'lyrics' && (
|
|
1187
|
+
<>
|
|
1188
|
+
{/* Waveform canvas */}
|
|
1189
|
+
<LyricsEditorCanvas
|
|
1190
|
+
lyricsData={lyricsData}
|
|
1191
|
+
selectedLineIndex={selectedLineIndex}
|
|
1192
|
+
onLineSelect={setSelectedLineIndex}
|
|
1193
|
+
vocalsWaveform={vocalsWaveform}
|
|
1194
|
+
songDuration={songDuration}
|
|
1195
|
+
currentPosition={currentPosition}
|
|
1196
|
+
isPlaying={isPlaying}
|
|
1197
|
+
/>
|
|
1198
|
+
|
|
1199
|
+
{/* Line detail canvas - zoomed view of selected line */}
|
|
1200
|
+
<LineDetailCanvas
|
|
1201
|
+
selectedLine={selectedLineIndex !== null ? lyricsData[selectedLineIndex] : null}
|
|
1202
|
+
vocalsWaveform={vocalsWaveform}
|
|
1203
|
+
songDuration={songDuration}
|
|
1204
|
+
currentPosition={currentPosition}
|
|
1205
|
+
isPlaying={isPlaying}
|
|
1206
|
+
/>
|
|
1207
|
+
|
|
1208
|
+
{/* Audio playback controls */}
|
|
1209
|
+
{audioElements.length > 0 && (
|
|
1210
|
+
<div className="flex items-center gap-2 px-2 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded flex-shrink-0">
|
|
1211
|
+
<button
|
|
1212
|
+
onClick={togglePlayback}
|
|
1213
|
+
className="flex items-center gap-1.5 px-3 py-1 bg-blue-600 border-blue-600 rounded text-white cursor-pointer text-xs transition-colors hover:bg-blue-700"
|
|
1214
|
+
>
|
|
1215
|
+
<span className="material-icons text-base">
|
|
1216
|
+
{isPlaying ? 'pause' : 'play_arrow'}
|
|
1217
|
+
</span>
|
|
1218
|
+
{isPlaying ? 'Pause' : 'Play'}
|
|
1219
|
+
</button>
|
|
1220
|
+
<div className="flex gap-1.5 flex-wrap flex-1 items-center">
|
|
1221
|
+
{audioElements.map((el, index) => (
|
|
1222
|
+
<div
|
|
1223
|
+
key={index}
|
|
1224
|
+
className="flex items-center gap-1 px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded"
|
|
1225
|
+
>
|
|
1226
|
+
<span className="text-[11px] font-semibold text-gray-900 dark:text-white min-w-[45px]">
|
|
1227
|
+
{el.name}
|
|
1228
|
+
</span>
|
|
1229
|
+
<button
|
|
1230
|
+
onClick={() => toggleMute(index)}
|
|
1231
|
+
className={`flex items-center justify-center w-6 h-6 p-0.5 rounded cursor-pointer transition-colors ${el.muted ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-green-600 text-white hover:bg-green-700'}`}
|
|
1232
|
+
title={el.muted ? 'Unmute' : 'Mute'}
|
|
1233
|
+
>
|
|
1234
|
+
<span className="material-icons text-sm">
|
|
1235
|
+
{el.muted ? 'volume_off' : 'volume_up'}
|
|
1236
|
+
</span>
|
|
1237
|
+
</button>
|
|
1238
|
+
</div>
|
|
1239
|
+
))}
|
|
1240
|
+
</div>
|
|
1241
|
+
<button
|
|
1242
|
+
onClick={handleExportLyrics}
|
|
1243
|
+
className="flex items-center gap-1.5 px-3 py-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white cursor-pointer text-xs transition-colors hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1244
|
+
disabled={!lyricsData || lyricsData.length === 0}
|
|
1245
|
+
title="Export lyrics as text file"
|
|
1246
|
+
>
|
|
1247
|
+
<span className="material-icons text-base">download</span>
|
|
1248
|
+
Export
|
|
1249
|
+
</button>
|
|
1250
|
+
<button
|
|
1251
|
+
onClick={handleResetLyrics}
|
|
1252
|
+
className="flex items-center gap-1.5 px-3 py-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white cursor-pointer text-xs transition-colors hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1253
|
+
disabled={!hasChanges}
|
|
1254
|
+
title="Reset to original lyrics"
|
|
1255
|
+
>
|
|
1256
|
+
<span className="material-icons text-base">restore</span>
|
|
1257
|
+
Reset
|
|
1258
|
+
</button>
|
|
1259
|
+
<button
|
|
1260
|
+
onClick={handleAddLineAtStart}
|
|
1261
|
+
className="flex items-center gap-1.5 px-3 py-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white cursor-pointer text-xs transition-colors hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1262
|
+
disabled={!canAddLineAtStart()}
|
|
1263
|
+
title={
|
|
1264
|
+
canAddLineAtStart()
|
|
1265
|
+
? 'Add line at beginning'
|
|
1266
|
+
: 'Not enough space (need 0.6s gap)'
|
|
1267
|
+
}
|
|
1268
|
+
>
|
|
1269
|
+
<span className="material-icons text-base">add</span>
|
|
1270
|
+
Add First Line
|
|
1271
|
+
</button>
|
|
1272
|
+
</div>
|
|
1273
|
+
)}
|
|
1274
|
+
|
|
1275
|
+
{/* Scrollable container for lyrics and corrections */}
|
|
1276
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
|
1277
|
+
{/* Lyrics lines */}
|
|
1278
|
+
<div className="flex flex-col gap-0 p-3 overflow-y-auto flex-1">
|
|
1279
|
+
{lyricsData && lyricsData.length > 0 ? (
|
|
1280
|
+
lyricsData.map((line, index) => (
|
|
1281
|
+
<LyricLine
|
|
1282
|
+
key={`lyric-${index}`}
|
|
1283
|
+
line={line}
|
|
1284
|
+
index={index}
|
|
1285
|
+
isSelected={selectedLineIndex === index}
|
|
1286
|
+
onSelect={setSelectedLineIndex}
|
|
1287
|
+
onUpdate={handleLineUpdate}
|
|
1288
|
+
onDelete={handleLineDelete}
|
|
1289
|
+
onAddAfter={handleAddLineAfter}
|
|
1290
|
+
onSplit={handleLineSplit}
|
|
1291
|
+
onPlaySection={handlePlayLineSection}
|
|
1292
|
+
onAdjustStartTime={(delta) => {
|
|
1293
|
+
setSelectedLineIndex(index);
|
|
1294
|
+
const currentStart = line.start || line.startTimeSec || 0;
|
|
1295
|
+
const newStart = Math.max(0, currentStart + delta);
|
|
1296
|
+
handleLineUpdate(index, {
|
|
1297
|
+
...line,
|
|
1298
|
+
start: newStart,
|
|
1299
|
+
startTimeSec: newStart,
|
|
1300
|
+
});
|
|
1301
|
+
}}
|
|
1302
|
+
onAdjustEndTime={(delta) => {
|
|
1303
|
+
setSelectedLineIndex(index);
|
|
1304
|
+
const currentEnd = line.end || line.endTimeSec || 0;
|
|
1305
|
+
const newEnd = Math.max(0, currentEnd + delta);
|
|
1306
|
+
handleLineUpdate(index, {
|
|
1307
|
+
...line,
|
|
1308
|
+
end: newEnd,
|
|
1309
|
+
endTimeSec: newEnd,
|
|
1310
|
+
});
|
|
1311
|
+
}}
|
|
1312
|
+
canAddAfter={canAddLineAfter(index)}
|
|
1313
|
+
canSplit={canSplit(index)}
|
|
1314
|
+
hasOverlap={checkOverlap(index)}
|
|
1315
|
+
/>
|
|
1316
|
+
))
|
|
1317
|
+
) : (
|
|
1318
|
+
<div className="text-center p-10 text-gray-500 dark:text-gray-400 text-base">
|
|
1319
|
+
No lyrics available. Load a KAI file with lyrics to edit.
|
|
1320
|
+
</div>
|
|
1321
|
+
)}
|
|
1322
|
+
</div>
|
|
1323
|
+
|
|
1324
|
+
{/* AI Corrections Section */}
|
|
1325
|
+
{(rejections.length > 0 || suggestions.length > 0) && (
|
|
1326
|
+
<div className="mb-6 p-4 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md">
|
|
1327
|
+
<h3 className="text-base font-semibold m-0 mb-4 text-gray-900 dark:text-white">
|
|
1328
|
+
AI Corrections & Suggestions
|
|
1329
|
+
</h3>
|
|
1330
|
+
|
|
1331
|
+
{rejections.map((rejection, rejectionIndex) => (
|
|
1332
|
+
<LyricRejection
|
|
1333
|
+
key={`rejection-${rejectionIndex}`}
|
|
1334
|
+
rejection={rejection}
|
|
1335
|
+
rejectionIndex={rejectionIndex}
|
|
1336
|
+
onAccept={handleAcceptRejection}
|
|
1337
|
+
onDelete={handleDeleteRejection}
|
|
1338
|
+
/>
|
|
1339
|
+
))}
|
|
1340
|
+
|
|
1341
|
+
{suggestions.map((suggestion, suggestionIndex) => (
|
|
1342
|
+
<LyricSuggestion
|
|
1343
|
+
key={`suggestion-${suggestionIndex}`}
|
|
1344
|
+
suggestion={suggestion}
|
|
1345
|
+
suggestionIndex={suggestionIndex}
|
|
1346
|
+
onAccept={handleAcceptSuggestion}
|
|
1347
|
+
onDelete={handleDeleteSuggestion}
|
|
1348
|
+
/>
|
|
1349
|
+
))}
|
|
1350
|
+
</div>
|
|
1351
|
+
)}
|
|
1352
|
+
</div>
|
|
1353
|
+
</>
|
|
1354
|
+
)}
|
|
1355
|
+
</div>
|
|
1356
|
+
)}
|
|
1357
|
+
|
|
1358
|
+
{/* Toast notification */}
|
|
1359
|
+
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
|
1360
|
+
</div>
|
|
1361
|
+
);
|
|
1362
|
+
}
|