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.
Files changed (290) hide show
  1. package/README.md +558 -0
  2. package/bin/loukai.js +32 -0
  3. package/package.json +243 -0
  4. package/src/main/appState.js +250 -0
  5. package/src/main/audioEngine.js +478 -0
  6. package/src/main/creator/conversionService.js +503 -0
  7. package/src/main/creator/downloadManager.js +1128 -0
  8. package/src/main/creator/ffmpegService.js +487 -0
  9. package/src/main/creator/installLogger.js +51 -0
  10. package/src/main/creator/keyDetection.js +212 -0
  11. package/src/main/creator/llmService.js +370 -0
  12. package/src/main/creator/lrclibService.js +340 -0
  13. package/src/main/creator/python/crepe_runner.py +189 -0
  14. package/src/main/creator/python/demucs_runner.py +158 -0
  15. package/src/main/creator/python/whisper_runner.py +172 -0
  16. package/src/main/creator/pythonRunner.js +268 -0
  17. package/src/main/creator/stemBuilder.js +491 -0
  18. package/src/main/creator/systemChecker.js +474 -0
  19. package/src/main/handlers/appHandlers.js +45 -0
  20. package/src/main/handlers/audioHandlers.js +33 -0
  21. package/src/main/handlers/autotuneHandlers.js +28 -0
  22. package/src/main/handlers/canvasHandlers.js +84 -0
  23. package/src/main/handlers/creatorHandlers.js +159 -0
  24. package/src/main/handlers/editorHandlers.js +98 -0
  25. package/src/main/handlers/effectsHandlers.js +100 -0
  26. package/src/main/handlers/fileHandlers.js +45 -0
  27. package/src/main/handlers/index.js +78 -0
  28. package/src/main/handlers/libraryHandlers.js +96 -0
  29. package/src/main/handlers/mixerHandlers.js +64 -0
  30. package/src/main/handlers/playerHandlers.js +39 -0
  31. package/src/main/handlers/preferencesHandlers.js +46 -0
  32. package/src/main/handlers/queueHandlers.js +81 -0
  33. package/src/main/handlers/rendererHandlers.js +63 -0
  34. package/src/main/handlers/settingsHandlers.js +42 -0
  35. package/src/main/handlers/webServerHandlers.js +105 -0
  36. package/src/main/main.js +2351 -0
  37. package/src/main/preload.js +252 -0
  38. package/src/main/settingsManager.js +139 -0
  39. package/src/main/statePersistence.js +193 -0
  40. package/src/main/utils/pathValidator.js +112 -0
  41. package/src/main/webServer.js +2535 -0
  42. package/src/native/autotune.js +417 -0
  43. package/src/renderer/adapters/ElectronBridge.js +677 -0
  44. package/src/renderer/canvas.html +80 -0
  45. package/src/renderer/components/App.jsx +303 -0
  46. package/src/renderer/components/AppRoot.jsx +37 -0
  47. package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
  48. package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
  49. package/src/renderer/components/MixerTab.jsx +233 -0
  50. package/src/renderer/components/MixerTabWrapper.jsx +31 -0
  51. package/src/renderer/components/PortalSelect.jsx +239 -0
  52. package/src/renderer/components/QueueTab.jsx +116 -0
  53. package/src/renderer/components/RequestsListWrapper.jsx +78 -0
  54. package/src/renderer/components/ServerTab.jsx +472 -0
  55. package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
  56. package/src/renderer/components/StatusBar.jsx +92 -0
  57. package/src/renderer/components/TabNavigation.jsx +77 -0
  58. package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
  59. package/src/renderer/components/creator/CreateTab.jsx +1236 -0
  60. package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
  61. package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
  62. package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
  63. package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
  64. package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
  65. package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
  66. package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
  67. package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
  68. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
  69. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
  70. package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
  71. package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
  72. package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
  73. package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
  74. package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
  75. package/src/renderer/dist/renderer.css +1 -0
  76. package/src/renderer/dist/renderer.js +62 -0
  77. package/src/renderer/dist/renderer.js.map +1 -0
  78. package/src/renderer/dist/renderer.woff2 +0 -0
  79. package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
  80. package/src/renderer/index.html +24 -0
  81. package/src/renderer/index.html.backup +372 -0
  82. package/src/renderer/js/PlayerInterface.js +267 -0
  83. package/src/renderer/js/autoTuneWorklet.js +224 -0
  84. package/src/renderer/js/butterchurnVerify.js +46 -0
  85. package/src/renderer/js/canvas-app.js +114 -0
  86. package/src/renderer/js/cdgPlayer.js +685 -0
  87. package/src/renderer/js/kaiPlayer.js +1200 -0
  88. package/src/renderer/js/karaokeRenderer.js +3392 -0
  89. package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
  90. package/src/renderer/js/microphoneEngine.js +656 -0
  91. package/src/renderer/js/musicAnalysisWorklet.js +216 -0
  92. package/src/renderer/js/phaseVocoderWorklet.js +341 -0
  93. package/src/renderer/js/player.js +232 -0
  94. package/src/renderer/js/referencePitchTracker.js +130 -0
  95. package/src/renderer/js/songLoaders.js +334 -0
  96. package/src/renderer/js/soundtouch-worklet.js +1395 -0
  97. package/src/renderer/js/webrtcManager.js +511 -0
  98. package/src/renderer/lib/butterchurn.min.js +6739 -0
  99. package/src/renderer/lib/butterchurnPresets.min.js +1 -0
  100. package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
  101. package/src/renderer/lib/cdgraphics.js +299 -0
  102. package/src/renderer/public/js/autoTuneWorklet.js +224 -0
  103. package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
  104. package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
  105. package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
  106. package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
  107. package/src/renderer/react-entry.jsx +44 -0
  108. package/src/renderer/styles/tailwind.css +106 -0
  109. package/src/renderer/utils/qrCodeGenerator.js +98 -0
  110. package/src/renderer/vite.config.js +31 -0
  111. package/src/shared/adapters/BridgeInterface.js +195 -0
  112. package/src/shared/components/EffectsPanel.jsx +177 -0
  113. package/src/shared/components/LibraryPanel.jsx +701 -0
  114. package/src/shared/components/LineDetailCanvas.jsx +167 -0
  115. package/src/shared/components/LyricLine.jsx +505 -0
  116. package/src/shared/components/LyricRejection.jsx +84 -0
  117. package/src/shared/components/LyricSuggestion.jsx +80 -0
  118. package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
  119. package/src/shared/components/MixerPanel.jsx +94 -0
  120. package/src/shared/components/PlayerControls.jsx +206 -0
  121. package/src/shared/components/PortalSelect.jsx +239 -0
  122. package/src/shared/components/QueueList.jsx +365 -0
  123. package/src/shared/components/QuickSearch.jsx +126 -0
  124. package/src/shared/components/RequestsList.jsx +121 -0
  125. package/src/shared/components/SongEditor.jsx +1362 -0
  126. package/src/shared/components/SongInfoBar.jsx +81 -0
  127. package/src/shared/components/ThemeToggle.jsx +106 -0
  128. package/src/shared/components/Toast.jsx +30 -0
  129. package/src/shared/components/VisualizationSettings.jsx +243 -0
  130. package/src/shared/constants.js +95 -0
  131. package/src/shared/context/BridgeContext.jsx +32 -0
  132. package/src/shared/contexts/AudioContext.jsx +37 -0
  133. package/src/shared/contexts/PlayerContext.jsx +66 -0
  134. package/src/shared/contexts/SettingsContext.jsx +50 -0
  135. package/src/shared/defaults.js +158 -0
  136. package/src/shared/formatUtils.js +59 -0
  137. package/src/shared/formatUtils.test.js +207 -0
  138. package/src/shared/hooks/useAppState.js +97 -0
  139. package/src/shared/hooks/useAudioEngine.js +264 -0
  140. package/src/shared/hooks/usePlayer.js +89 -0
  141. package/src/shared/hooks/useSettingsPersistence.js +74 -0
  142. package/src/shared/hooks/useWebRTC.js +118 -0
  143. package/src/shared/ipcContracts.js +299 -0
  144. package/src/shared/package.json +3 -0
  145. package/src/shared/services/creatorService.js +373 -0
  146. package/src/shared/services/creatorService.test.js +413 -0
  147. package/src/shared/services/editorService.js +213 -0
  148. package/src/shared/services/editorService.test.js +219 -0
  149. package/src/shared/services/effectsService.js +271 -0
  150. package/src/shared/services/effectsService.test.js +418 -0
  151. package/src/shared/services/libraryService.js +438 -0
  152. package/src/shared/services/libraryService.test.js +474 -0
  153. package/src/shared/services/mixerService.js +172 -0
  154. package/src/shared/services/mixerService.test.js +399 -0
  155. package/src/shared/services/playerService.js +221 -0
  156. package/src/shared/services/playerService.test.js +357 -0
  157. package/src/shared/services/preferencesService.js +219 -0
  158. package/src/shared/services/queueService.js +226 -0
  159. package/src/shared/services/queueService.test.js +430 -0
  160. package/src/shared/services/requestsService.js +155 -0
  161. package/src/shared/services/requestsService.test.js +362 -0
  162. package/src/shared/services/serverSettingsService.js +151 -0
  163. package/src/shared/services/settingsService.js +257 -0
  164. package/src/shared/services/settingsService.test.js +295 -0
  165. package/src/shared/state/StateManager.js +263 -0
  166. package/src/shared/utils/audio.js +42 -0
  167. package/src/shared/utils/format.js +32 -0
  168. package/src/shared/utils/lyricsUtils.js +162 -0
  169. package/src/test/setup.js +40 -0
  170. package/src/utils/cdgLoader.js +180 -0
  171. package/src/utils/m4aLoader.js +333 -0
  172. package/src/web/App.jsx +578 -0
  173. package/src/web/adapters/WebBridge.js +428 -0
  174. package/src/web/components/PlayerSettingsPanel.jsx +231 -0
  175. package/src/web/components/SongSearch.jsx +180 -0
  176. package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
  177. package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
  178. package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
  179. package/src/web/dist/index.html +15 -0
  180. package/src/web/index.html +14 -0
  181. package/src/web/main.jsx +10 -0
  182. package/src/web/package-lock.json +1765 -0
  183. package/src/web/pages/SongRequestPage.jsx +619 -0
  184. package/src/web/styles/tailwind.css +68 -0
  185. package/src/web/vite.config.js +27 -0
  186. package/static/fonts/material-icons.woff2 +0 -0
  187. package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
  188. package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
  189. package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
  190. package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
  191. 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
  192. package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
  193. proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
  194. package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
  195. package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
  196. package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
  197. package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
  198. package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
  199. package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
  200. package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
  201. package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
  202. package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
  203. package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
  204. package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
  205. package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
  206. package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
  207. package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
  208. package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
  209. package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
  210. package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
  211. package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
  212. package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
  213. package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
  214. package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
  215. package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
  216. package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
  217. package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
  218. package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
  219. package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
  220. package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
  221. package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
  222. package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
  223. package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
  224. package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
  225. package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
  226. package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
  227. package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
  228. package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
  229. package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
  230. package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
  231. package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
  232. package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
  233. package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
  234. package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
  235. package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
  236. package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
  237. package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
  238. package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
  239. package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
  240. package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
  241. package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
  242. package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
  243. package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
  244. package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
  245. package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
  246. package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
  247. package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
  248. package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
  249. package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
  250. package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
  251. package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
  252. package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
  253. package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
  254. package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
  255. package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
  256. package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
  257. package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
  258. package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
  259. package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
  260. package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
  261. package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
  262. package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
  263. package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
  264. package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
  265. package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
  266. package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
  267. package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
  268. package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
  269. package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
  270. package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
  271. package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
  272. package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
  273. package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
  274. package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
  275. package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
  276. package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
  277. package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
  278. package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
  279. package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
  280. package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
  281. package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
  282. 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
  283. package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
  284. package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
  285. package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
  286. package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
  287. package/static/images/logo-512.png +0 -0
  288. package/static/images/logo.png +0 -0
  289. package/static/loukai-logo.png +0 -0
  290. 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
+ }