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,2351 @@
1
+ import { app, BrowserWindow, ipcMain, dialog, Menu } from 'electron';
2
+ import path, { dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import fs from 'fs';
5
+ import fsPromises from 'fs/promises';
6
+ import os from 'os';
7
+ import yauzl from 'yauzl';
8
+ import { io } from 'socket.io-client';
9
+ import AudioEngine from './audioEngine.js';
10
+ import CDGLoader from '../utils/cdgLoader.js';
11
+ import M4ALoader from '../utils/m4aLoader.js';
12
+ import { Atoms as M4AAtoms } from 'm4a-stems';
13
+ import SettingsManager from './settingsManager.js';
14
+ import WebServer from './webServer.js';
15
+ import AppState from './appState.js';
16
+ import StatePersistence from './statePersistence.js';
17
+ import * as queueService from '../shared/services/queueService.js';
18
+ import * as libraryService from '../shared/services/libraryService.js';
19
+ import * as playerService from '../shared/services/playerService.js';
20
+ import * as serverSettingsService from '../shared/services/serverSettingsService.js';
21
+ import {
22
+ initSettingsService,
23
+ loadAndSync,
24
+ getBroadcastChannel,
25
+ } from '../shared/services/settingsService.js';
26
+
27
+ console.log('📦 About to import registerAllHandlers...');
28
+ import { registerAllHandlers } from './handlers/index.js';
29
+ console.log('✅ registerAllHandlers imported:', typeof registerAllHandlers);
30
+
31
+ // ESM equivalent of __dirname
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = dirname(__filename);
34
+
35
+ // TODO: Electron 38+ has Wayland bugs causing select dropdowns to render at wrong
36
+ // position (top-left corner). Major Electron apps (VS Code, Slack, Discord) default
37
+ // to XWayland, but forcing X11 mode breaks WebGL on some systems.
38
+ // For now, we accept the select dropdown bug until Electron fixes Wayland support.
39
+ // See: https://github.com/electron/electron/issues/44607
40
+ // Workaround options:
41
+ // - Use custom select component (breaks design principle of using native elements)
42
+ // - Wait for Electron to fix Wayland popup positioning
43
+ // - Let users manually set --ozone-platform=x11 if they prefer working dropdowns over WebGL
44
+
45
+ class KaiPlayerApp {
46
+ constructor() {
47
+ this.mainWindow = null;
48
+ this.canvasWindow = null;
49
+ this.audioEngine = null;
50
+ this.currentSong = null;
51
+ this.isDev = process.argv.includes('--dev');
52
+ this.settings = new SettingsManager();
53
+ this.webServer = null;
54
+ this.socket = null;
55
+ this.songQueue = [];
56
+ this.positionTimer = null;
57
+ this.libraryManager = null;
58
+ this.cachedLibrary = null; // Store library cache independently
59
+ this.isQuitting = false; // Track if app is quitting to avoid duplicate cleanup
60
+ this.canvasStreaming = {
61
+ isStreaming: false,
62
+ stream: null,
63
+ reader: null,
64
+ port: null,
65
+ inflight: 0,
66
+ MAX_INFLIGHT: 2,
67
+ };
68
+
69
+ // Store renderer playback state for position broadcasting
70
+ this.rendererPlaybackState = {
71
+ isPlaying: false,
72
+ currentTime: 0,
73
+ };
74
+
75
+ // Canonical application state
76
+ this.appState = new AppState();
77
+
78
+ // State persistence
79
+ this.statePersistence = new StatePersistence(this.appState);
80
+
81
+ // Set up state change listeners
82
+ this.setupStateListeners();
83
+ }
84
+
85
+ setupStateListeners() {
86
+ // When playback state changes, broadcast to web clients AND renderer
87
+ this.appState.on('playbackStateChanged', (playbackState, _changes) => {
88
+ if (this.webServer) {
89
+ this.webServer.broadcastPlaybackState(playbackState);
90
+ }
91
+ // Send to renderer for React components
92
+ this.sendToRenderer('playback:state', playbackState);
93
+ });
94
+
95
+ // When current song changes, broadcast to web clients AND renderer
96
+ this.appState.on('currentSongChanged', (song) => {
97
+ if (this.webServer && song) {
98
+ // Pass the complete song object to preserve path and requester
99
+ this.webServer.broadcastSongLoaded(song);
100
+ }
101
+ // Send to renderer for React components
102
+ this.sendToRenderer('song:changed', song);
103
+ });
104
+
105
+ // When queue changes, broadcast to web clients and renderer
106
+ this.appState.on('queueChanged', (queue) => {
107
+ // Broadcast to web clients
108
+ if (this.webServer) {
109
+ this.webServer.io?.emit('queue-update', {
110
+ queue,
111
+ currentSong: this.appState.state.currentSong,
112
+ });
113
+ }
114
+
115
+ // Send to renderer
116
+ this.sendToRenderer('queue:updated', queue);
117
+ });
118
+
119
+ // When mixer changes, broadcast to web clients AND renderer
120
+ this.appState.on('mixerChanged', (mixer) => {
121
+ if (this.webServer) {
122
+ this.webServer.io?.emit('mixer-update', mixer);
123
+ }
124
+ // Send to renderer for React components
125
+ this.sendToRenderer('mixer:state', mixer);
126
+ });
127
+
128
+ // When effects change, broadcast to web clients AND renderer
129
+ this.appState.on('effectsChanged', (effects) => {
130
+ // Get disabled effects from settings, not AppState
131
+ const waveformPrefs = this.settings.get('waveformPreferences', {});
132
+ const effectsWithCorrectDisabled = {
133
+ ...effects,
134
+ disabled: waveformPrefs.disabledEffects || [],
135
+ };
136
+
137
+ if (this.webServer) {
138
+ this.webServer.io?.emit('effects-update', effectsWithCorrectDisabled);
139
+ }
140
+ // Send to renderer for React components
141
+ this.sendToRenderer('effects:changed', effectsWithCorrectDisabled);
142
+ });
143
+
144
+ // When preferences change, broadcast to web clients AND renderer
145
+ this.appState.on('preferencesChanged', (preferences) => {
146
+ if (this.webServer) {
147
+ this.webServer.io?.emit('preferences-update', preferences);
148
+ }
149
+
150
+ // Send to renderer so it can sync
151
+ this.sendToRenderer('preferences:updated', preferences);
152
+ });
153
+ }
154
+
155
+ async initialize() {
156
+ await app.whenReady();
157
+
158
+ console.log('🚀 App starting...', {
159
+ isPackaged: app.isPackaged,
160
+ __dirname,
161
+ resourcesPath: process.resourcesPath,
162
+ cwd: process.cwd(),
163
+ });
164
+
165
+ await this.settings.load();
166
+
167
+ // Initialize unified settings service with broadcast function
168
+ initSettingsService(this.settings, this.appState, (key, value) =>
169
+ this.broadcastSettingChange(key, value)
170
+ );
171
+
172
+ // Load and sync settings to AppState
173
+ await loadAndSync();
174
+
175
+ // Load persisted state (queue, mixer, effects)
176
+ await this.statePersistence.load();
177
+
178
+ this.createMainWindow();
179
+ this.createApplicationMenu();
180
+ this.setupIPC();
181
+ this.initializeAudioEngine();
182
+ await this.initializeWebServer();
183
+
184
+ // Start periodic state persistence
185
+ this.statePersistence.startPeriodicSave();
186
+
187
+ // Check if songs folder is set, prompt if not
188
+ await this.checkSongsFolder();
189
+ }
190
+
191
+ createMainWindow() {
192
+ // In production, resources are in app.asar or Resources folder
193
+ const resourcesPath = app.isPackaged ? process.resourcesPath : path.join(__dirname, '../..');
194
+
195
+ const iconPath = app.isPackaged
196
+ ? path.join(resourcesPath, 'static', 'images', 'logo.png')
197
+ : path.join(process.cwd(), 'static', 'images', 'logo.png');
198
+
199
+ const windowOptions = {
200
+ width: 1200,
201
+ height: 800,
202
+ minWidth: 800,
203
+ minHeight: 600,
204
+ autoHideMenuBar: true, // Hide menu bar for cleaner, modern UI
205
+ webPreferences: {
206
+ nodeIntegration: true,
207
+ contextIsolation: false,
208
+ preload: path.join(__dirname, 'preload.js'),
209
+ },
210
+ title: 'Loukai',
211
+ };
212
+
213
+ // Only set icon if file exists
214
+ if (fs.existsSync(iconPath)) {
215
+ windowOptions.icon = iconPath;
216
+ } else {
217
+ console.warn('⚠️ Icon not found at:', iconPath);
218
+ }
219
+
220
+ this.mainWindow = new BrowserWindow(windowOptions);
221
+
222
+ const rendererPath = path.join(__dirname, '../renderer/index.html');
223
+ this.mainWindow.loadFile(rendererPath);
224
+
225
+ // DevTools: Use Ctrl+Shift+I (or Cmd+Option+I on Mac) to open manually
226
+
227
+ // Log all console messages from renderer
228
+ this.mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
229
+ console.log(`[Renderer ${level}] ${message} (${sourceId}:${line})`);
230
+ });
231
+
232
+ // Log renderer loading events
233
+ this.mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
234
+ console.error('❌ Renderer failed to load:', errorCode, errorDescription);
235
+ });
236
+
237
+ this.mainWindow.webContents.on('did-finish-load', () => {
238
+ console.log('✅ Renderer finished loading');
239
+ });
240
+
241
+ // Set dock icon on macOS
242
+ if (process.platform === 'darwin' && fs.existsSync(iconPath)) {
243
+ app.dock?.setIcon(iconPath);
244
+ }
245
+
246
+ // Handle renderer process errors without showing dialogs
247
+ this.mainWindow.webContents.on('crashed', (event) => {
248
+ console.error('🚨 Renderer process crashed:', event);
249
+ });
250
+
251
+ this.mainWindow.webContents.on('unresponsive', () => {
252
+ console.error('🚨 Renderer process became unresponsive');
253
+ });
254
+
255
+ // Prevent JavaScript errors from showing as alert dialogs
256
+ this.mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
257
+ if (level === 3) {
258
+ // Error level
259
+ console.error(`🚨 Renderer error at ${sourceId}:${line}:`, message);
260
+ event.preventDefault();
261
+ }
262
+ });
263
+
264
+ // Handle renderer process errors
265
+ this.mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
266
+ console.error('🚨 Renderer failed to load:', errorCode, errorDescription);
267
+ });
268
+
269
+ if (this.isDev) {
270
+ this.mainWindow.webContents.openDevTools();
271
+ }
272
+
273
+ // Add F12 key handler for DevTools
274
+ this.mainWindow.webContents.on('before-input-event', (event, input) => {
275
+ if (input.type === 'keyDown') {
276
+ // F12 key
277
+ if (input.key === 'F12') {
278
+ event.preventDefault();
279
+ console.log('F12 pressed, toggling DevTools...');
280
+ try {
281
+ if (this.mainWindow.webContents.isDevToolsOpened()) {
282
+ this.mainWindow.webContents.closeDevTools();
283
+ } else {
284
+ this.mainWindow.webContents.openDevTools();
285
+ }
286
+ } catch (error) {
287
+ console.error('Failed to toggle DevTools:', error);
288
+ }
289
+ }
290
+ // Ctrl+Shift+I
291
+ if ((input.control || input.meta) && input.shift && input.key === 'I') {
292
+ event.preventDefault();
293
+ console.log('Ctrl+Shift+I pressed, toggling DevTools...');
294
+ try {
295
+ if (this.mainWindow.webContents.isDevToolsOpened()) {
296
+ this.mainWindow.webContents.closeDevTools();
297
+ } else {
298
+ this.mainWindow.webContents.openDevTools();
299
+ }
300
+ } catch (error) {
301
+ console.error('Failed to toggle DevTools:', error);
302
+ }
303
+ }
304
+ }
305
+ });
306
+
307
+ this.mainWindow.on('closed', () => {
308
+ this.mainWindow = null;
309
+ if (this.canvasWindow) {
310
+ this.canvasWindow.close();
311
+ }
312
+ if (this.audioEngine) {
313
+ this.audioEngine.stop();
314
+ }
315
+ });
316
+ }
317
+
318
+ createCanvasWindow() {
319
+ if (this.canvasWindow) {
320
+ this.canvasWindow.focus();
321
+ return;
322
+ }
323
+
324
+ this.canvasWindow = new BrowserWindow({
325
+ width: 1280,
326
+ height: 720,
327
+ minWidth: 640,
328
+ minHeight: 360,
329
+ webPreferences: {
330
+ nodeIntegration: true,
331
+ contextIsolation: false,
332
+ },
333
+ title: 'Canvas Window',
334
+ show: false,
335
+ });
336
+
337
+ // Load canvas.html file instead of inline HTML
338
+ const canvasHtmlPath = path.join(__dirname, '../renderer/canvas.html');
339
+ this.canvasWindow.loadFile(canvasHtmlPath);
340
+
341
+ this.canvasWindow.once('ready-to-show', () => {
342
+ this.canvasWindow.show();
343
+ // Don't start streaming immediately - wait for child to signal ready
344
+ });
345
+
346
+ this.canvasWindow.on('closed', () => {
347
+ console.log('🔴 Child window closed, stopping streaming and cleanup');
348
+ this.stopCanvasStreaming();
349
+ this.canvasWindow = null;
350
+ });
351
+
352
+ if (this.isDev) {
353
+ this.canvasWindow.webContents.openDevTools();
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Helper: Send IPC command to main renderer and wait for response
359
+ * Replaces executeJavaScript pattern with proper IPC messaging
360
+ */
361
+ sendWebRTCCommand(command, ...args) {
362
+ return new Promise((resolve, reject) => {
363
+ const timeout = setTimeout(() => {
364
+ ipcMain.removeListener(`webrtc:${command}-response`, responseHandler);
365
+ reject(new Error(`WebRTC command timeout: ${command}`));
366
+ }, 10000); // 10 second timeout
367
+
368
+ const responseHandler = (event, result) => {
369
+ clearTimeout(timeout);
370
+ ipcMain.removeListener(`webrtc:${command}-response`, responseHandler);
371
+ resolve(result);
372
+ };
373
+
374
+ ipcMain.once(`webrtc:${command}-response`, responseHandler);
375
+ this.mainWindow.webContents.send(`webrtc:${command}`, ...args);
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Helper: Send IPC command to canvas window and wait for response
381
+ * Replaces executeJavaScript pattern with proper IPC messaging for receiver
382
+ */
383
+ sendCanvasWebRTCCommand(command, ...args) {
384
+ return new Promise((resolve, reject) => {
385
+ const timeout = setTimeout(() => {
386
+ ipcMain.removeListener(`webrtc:${command}-response`, responseHandler);
387
+ reject(new Error(`Canvas WebRTC command timeout: ${command}`));
388
+ }, 10000); // 10 second timeout
389
+
390
+ const responseHandler = (event, result) => {
391
+ clearTimeout(timeout);
392
+ ipcMain.removeListener(`webrtc:${command}-response`, responseHandler);
393
+ resolve(result);
394
+ };
395
+
396
+ ipcMain.once(`webrtc:${command}-response`, responseHandler);
397
+ this.canvasWindow.webContents.send(`webrtc:${command}`, ...args);
398
+ });
399
+ }
400
+
401
+ async startCanvasStreaming() {
402
+ if (this.canvasStreaming.isStreaming || !this.canvasWindow || !this.mainWindow) {
403
+ return;
404
+ }
405
+
406
+ // Only proceed if child window is still open and not destroyed
407
+ if (this.canvasWindow.isDestroyed()) {
408
+ console.log('❌ Child window destroyed, cannot start streaming');
409
+ return;
410
+ }
411
+
412
+ try {
413
+ console.log('Starting WebRTC canvas streaming...');
414
+
415
+ // Set up WebRTC sender in main window via IPC
416
+ const senderResult = await this.sendWebRTCCommand('setupSender');
417
+
418
+ if (!senderResult.success) {
419
+ throw new Error('Sender setup failed: ' + senderResult.error);
420
+ }
421
+
422
+ // Set up WebRTC receiver in child window via IPC
423
+ const receiverResult = await this.sendCanvasWebRTCCommand('setupReceiver');
424
+
425
+ if (!receiverResult.success) {
426
+ throw new Error('Receiver setup failed: ' + receiverResult.error);
427
+ }
428
+
429
+ // Establish WebRTC connection
430
+ await this.establishWebRTCConnection();
431
+
432
+ this.canvasStreaming.isStreaming = true;
433
+ console.log('✅ WebRTC canvas streaming started successfully');
434
+ } catch (error) {
435
+ console.error('Canvas streaming setup error:', error);
436
+ }
437
+ }
438
+
439
+ async establishWebRTCConnection() {
440
+ console.log('🤝 Starting WebRTC handshake...');
441
+
442
+ let offer;
443
+ try {
444
+ // Create offer in sender (main window) via IPC
445
+ console.log('📤 Creating offer in sender...');
446
+
447
+ offer = await this.sendWebRTCCommand('createOffer');
448
+
449
+ if (offer.error) {
450
+ throw new Error('Sender error: ' + offer.error);
451
+ }
452
+
453
+ console.log('✅ Offer creation successful, moving to receiver...');
454
+ } catch (error) {
455
+ console.error('❌ Failed to create offer:', error);
456
+ throw error;
457
+ }
458
+
459
+ try {
460
+ console.log('📥 Setting offer in receiver and creating answer...');
461
+
462
+ // First check if child window is ready
463
+ if (!this.canvasWindow || this.canvasWindow.isDestroyed()) {
464
+ throw new Error('Child window is not available');
465
+ }
466
+
467
+ // Check if receiver is ready via IPC
468
+ console.log('🔍 Checking if child window is ready...');
469
+ const childReady = await this.sendCanvasWebRTCCommand('checkReceiverReady');
470
+ console.log('🏓 Child window status:', childReady);
471
+
472
+ if (!childReady.hasReceiverPC) {
473
+ throw new Error('Receiver PC not found in child window');
474
+ }
475
+
476
+ // Set offer in receiver and create answer via IPC
477
+ const answer = await this.sendCanvasWebRTCCommand('setOfferAndCreateAnswer', offer);
478
+
479
+ if (answer.error) {
480
+ throw new Error('Receiver answer error: ' + answer.error);
481
+ }
482
+
483
+ // Set answer in sender via IPC
484
+ console.log('📤 Setting answer in sender...');
485
+ await this.sendWebRTCCommand('setAnswer', answer);
486
+
487
+ console.log('✅ WebRTC peer connection handshake complete');
488
+
489
+ // Wait a bit for ICE connection to establish
490
+ setTimeout(() => {
491
+ this.checkConnectionStatus();
492
+ }, 2000);
493
+ } catch (error) {
494
+ console.error('❌ Failed to establish WebRTC connection:', error);
495
+ }
496
+ }
497
+
498
+ async checkConnectionStatus() {
499
+ try {
500
+ const senderStatus = await this.sendWebRTCCommand('getSenderStatus');
501
+
502
+ const receiverStatus = await this.sendCanvasWebRTCCommand('getReceiverStatus');
503
+
504
+ console.log('📊 Connection Status:');
505
+ console.log(' Sender:', senderStatus);
506
+ console.log(' Receiver:', receiverStatus);
507
+ } catch (error) {
508
+ console.error('Error checking connection status:', error);
509
+ }
510
+ }
511
+
512
+ stopCanvasStreaming() {
513
+ if (!this.canvasStreaming.isStreaming) return;
514
+
515
+ try {
516
+ console.log('Stopping canvas streaming...');
517
+
518
+ // Cleanup sender (main window) via IPC
519
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
520
+ this.mainWindow.webContents.send('webrtc:cleanupSender');
521
+ }
522
+
523
+ // Cleanup receiver (child window) via IPC
524
+ if (this.canvasWindow && !this.canvasWindow.isDestroyed()) {
525
+ this.canvasWindow.webContents.send('webrtc:cleanupReceiver');
526
+ }
527
+
528
+ this.canvasStreaming.isStreaming = false;
529
+ console.log('Canvas streaming stopped');
530
+ } catch (error) {
531
+ console.error('Error stopping canvas streaming:', error);
532
+ }
533
+ }
534
+
535
+ createApplicationMenu() {
536
+ const template = [
537
+ {
538
+ label: 'File',
539
+ submenu: [
540
+ {
541
+ label: 'Open KAI File...',
542
+ accelerator: 'CmdOrCtrl+O',
543
+ click: async () => {
544
+ const result = await dialog.showOpenDialog(this.mainWindow, {
545
+ filters: [{ name: 'KAI Files', extensions: ['kai'] }],
546
+ properties: ['openFile'],
547
+ });
548
+
549
+ if (!result.canceled && result.filePaths.length > 0) {
550
+ await this.loadKaiFile(result.filePaths[0]);
551
+ }
552
+ },
553
+ },
554
+ { type: 'separator' },
555
+ {
556
+ label: 'Quit',
557
+ accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
558
+ click: () => {
559
+ app.quit();
560
+ },
561
+ },
562
+ ],
563
+ },
564
+ {
565
+ label: 'Edit',
566
+ submenu: [
567
+ {
568
+ label: 'Undo',
569
+ accelerator: 'CmdOrCtrl+Z',
570
+ role: 'undo',
571
+ },
572
+ {
573
+ label: 'Redo',
574
+ accelerator: 'Shift+CmdOrCtrl+Z',
575
+ role: 'redo',
576
+ },
577
+ { type: 'separator' },
578
+ {
579
+ label: 'Cut',
580
+ accelerator: 'CmdOrCtrl+X',
581
+ role: 'cut',
582
+ },
583
+ {
584
+ label: 'Copy',
585
+ accelerator: 'CmdOrCtrl+C',
586
+ role: 'copy',
587
+ },
588
+ {
589
+ label: 'Paste',
590
+ accelerator: 'CmdOrCtrl+V',
591
+ role: 'paste',
592
+ },
593
+ {
594
+ label: 'Select All',
595
+ accelerator: 'CmdOrCtrl+A',
596
+ role: 'selectAll',
597
+ },
598
+ ],
599
+ },
600
+ {
601
+ label: 'View',
602
+ submenu: [
603
+ {
604
+ label: 'Reload',
605
+ accelerator: 'CmdOrCtrl+R',
606
+ click: (item, focusedWindow) => {
607
+ if (focusedWindow) {
608
+ focusedWindow.reload();
609
+ }
610
+ },
611
+ },
612
+ {
613
+ label: 'Toggle Developer Tools',
614
+ accelerator: process.platform === 'darwin' ? 'Alt+Cmd+I' : 'Ctrl+Shift+I',
615
+ click: (item, focusedWindow) => {
616
+ console.log('Menu: Toggle Developer Tools clicked', {
617
+ hasFocusedWindow: Boolean(focusedWindow),
618
+ windowType: focusedWindow?.getTitle(),
619
+ });
620
+
621
+ // Use mainWindow directly if no focused window
622
+ const targetWindow = focusedWindow || this.mainWindow;
623
+
624
+ if (targetWindow) {
625
+ try {
626
+ if (targetWindow.webContents.isDevToolsOpened()) {
627
+ console.log('Closing DevTools...');
628
+ targetWindow.webContents.closeDevTools();
629
+ } else {
630
+ console.log('Opening DevTools...');
631
+ targetWindow.webContents.openDevTools();
632
+ }
633
+ } catch (error) {
634
+ console.error('Failed to toggle DevTools:', error);
635
+ }
636
+ } else {
637
+ console.error('No window available for DevTools toggle');
638
+ }
639
+ },
640
+ },
641
+ { type: 'separator' },
642
+ {
643
+ label: 'Actual Size',
644
+ accelerator: 'CmdOrCtrl+0',
645
+ click: (item, focusedWindow) => {
646
+ if (focusedWindow) {
647
+ focusedWindow.webContents.setZoomLevel(0);
648
+ }
649
+ },
650
+ },
651
+ {
652
+ label: 'Zoom In',
653
+ accelerator: 'CmdOrCtrl+Plus',
654
+ click: (item, focusedWindow) => {
655
+ if (focusedWindow) {
656
+ const currentZoom = focusedWindow.webContents.getZoomLevel();
657
+ focusedWindow.webContents.setZoomLevel(currentZoom + 0.5);
658
+ }
659
+ },
660
+ },
661
+ {
662
+ label: 'Zoom Out',
663
+ accelerator: 'CmdOrCtrl+-',
664
+ click: (item, focusedWindow) => {
665
+ if (focusedWindow) {
666
+ const currentZoom = focusedWindow.webContents.getZoomLevel();
667
+ focusedWindow.webContents.setZoomLevel(currentZoom - 0.5);
668
+ }
669
+ },
670
+ },
671
+ ],
672
+ },
673
+ ];
674
+
675
+ // macOS specific menu adjustments
676
+ if (process.platform === 'darwin') {
677
+ template.unshift({
678
+ label: app.getName(),
679
+ submenu: [
680
+ {
681
+ label: 'About ' + app.getName(),
682
+ role: 'about',
683
+ },
684
+ { type: 'separator' },
685
+ {
686
+ label: 'Services',
687
+ role: 'services',
688
+ submenu: [],
689
+ },
690
+ { type: 'separator' },
691
+ {
692
+ label: 'Hide ' + app.getName(),
693
+ accelerator: 'Command+H',
694
+ role: 'hide',
695
+ },
696
+ {
697
+ label: 'Hide Others',
698
+ accelerator: 'Command+Shift+H',
699
+ role: 'hideothers',
700
+ },
701
+ {
702
+ label: 'Show All',
703
+ role: 'unhide',
704
+ },
705
+ { type: 'separator' },
706
+ {
707
+ label: 'Quit',
708
+ accelerator: 'Command+Q',
709
+ click: () => {
710
+ app.quit();
711
+ },
712
+ },
713
+ ],
714
+ });
715
+
716
+ // Window menu for macOS
717
+ template.push({
718
+ label: 'Window',
719
+ submenu: [
720
+ {
721
+ label: 'Minimize',
722
+ accelerator: 'CmdOrCtrl+M',
723
+ role: 'minimize',
724
+ },
725
+ {
726
+ label: 'Close',
727
+ accelerator: 'CmdOrCtrl+W',
728
+ role: 'close',
729
+ },
730
+ ],
731
+ });
732
+ }
733
+
734
+ const menu = Menu.buildFromTemplate(template);
735
+ Menu.setApplicationMenu(menu);
736
+ }
737
+
738
+ initializeAudioEngine() {
739
+ try {
740
+ this.audioEngine = new AudioEngine();
741
+ this.audioEngine.initialize();
742
+
743
+ this.audioEngine.on('xrun', (count) => {
744
+ this.sendToRenderer('audio:xrun', count);
745
+ });
746
+
747
+ this.audioEngine.on('latencyUpdate', (latency) => {
748
+ this.sendToRenderer('audio:latency', latency);
749
+ });
750
+
751
+ this.audioEngine.on('mixChanged', (mixState) => {
752
+ this.sendToRenderer('mixer:state', mixState);
753
+ });
754
+ } catch (error) {
755
+ console.error('Failed to initialize audio engine:', error);
756
+ }
757
+ }
758
+
759
+ setupIPC() {
760
+ // All IPC handlers have been organized into handler modules
761
+ // See: src/main/handlers/
762
+ try {
763
+ console.log('🔧 Setting up IPC handlers...');
764
+ registerAllHandlers(this);
765
+ console.log('✅ IPC setup complete');
766
+ } catch (error) {
767
+ console.error('❌ Failed to setup IPC handlers:', error);
768
+ console.error('Stack:', error.stack);
769
+ throw error;
770
+ }
771
+ }
772
+
773
+ async scanForKaiFiles(folderPath) {
774
+ // fs already imported
775
+ const files = [];
776
+ const cdgMap = new Map(); // Track CDG files found
777
+ const mp3Map = new Map(); // Track MP3 files found
778
+
779
+ try {
780
+ const entries = await fsPromises.readdir(folderPath, { withFileTypes: true });
781
+
782
+ // First pass: collect files and identify types
783
+ for (const entry of entries) {
784
+ const fullPath = path.join(folderPath, entry.name);
785
+ const lowerName = entry.name.toLowerCase();
786
+ const baseName = entry.name.substring(0, entry.name.lastIndexOf('.'));
787
+
788
+ if (entry.isDirectory()) {
789
+ // Recursively scan subdirectories - intentional sequential processing
790
+ // eslint-disable-next-line no-await-in-loop
791
+ const subFiles = await this.scanForKaiFiles(fullPath);
792
+ files.push(...subFiles);
793
+ } else if (lowerName.endsWith('.kar') || lowerName.endsWith('.zip')) {
794
+ // CDG archive format (.kar or .zip) - sequential file I/O
795
+ // eslint-disable-next-line no-await-in-loop
796
+ const metadata = await this.extractCDGArchiveMetadata(fullPath);
797
+ if (metadata) {
798
+ // eslint-disable-next-line no-await-in-loop
799
+ const stats = await fsPromises.stat(fullPath);
800
+ files.push({
801
+ name: fullPath,
802
+ path: fullPath,
803
+ size: stats.size,
804
+ modified: stats.mtime,
805
+ folder: path.relative(this.settings.getSongsFolder(), folderPath) || '.',
806
+ format: 'cdg-archive',
807
+ ...metadata,
808
+ });
809
+ }
810
+ } else if (lowerName.endsWith('.m4a') || lowerName.endsWith('.mp4')) {
811
+ // M4A/MP4 format - check if it has karaoke data
812
+ // eslint-disable-next-line no-await-in-loop
813
+ const metadata = await this.extractM4AMetadata(fullPath);
814
+ if (metadata && metadata.hasKaraoke) {
815
+ // eslint-disable-next-line no-await-in-loop
816
+ const stats = await fsPromises.stat(fullPath);
817
+ files.push({
818
+ name: fullPath,
819
+ path: fullPath,
820
+ size: stats.size,
821
+ modified: stats.mtime,
822
+ folder: path.relative(this.settings.getSongsFolder(), folderPath) || '.',
823
+ format: 'm4a-stems',
824
+ ...metadata,
825
+ });
826
+ }
827
+ } else if (lowerName.endsWith('.cdg')) {
828
+ // Track CDG files for pairing
829
+ cdgMap.set(baseName, fullPath);
830
+ } else if (lowerName.endsWith('.mp3')) {
831
+ // Track MP3 files for pairing
832
+ mp3Map.set(baseName, fullPath);
833
+ }
834
+ }
835
+
836
+ // Second pass: match MP3 + CDG pairs (only add if BOTH files exist)
837
+ for (const [baseName, mp3Path] of mp3Map.entries()) {
838
+ const cdgPath = cdgMap.get(baseName);
839
+ if (cdgPath) {
840
+ // Found matching pair - sequential metadata extraction
841
+ // eslint-disable-next-line no-await-in-loop
842
+ const metadata = await this.extractCDGPairMetadata(mp3Path, cdgPath);
843
+ // eslint-disable-next-line no-await-in-loop
844
+ const stats = await fsPromises.stat(mp3Path);
845
+ files.push({
846
+ name: mp3Path,
847
+ path: mp3Path,
848
+ cdgPath: cdgPath,
849
+ size: stats.size,
850
+ modified: stats.mtime,
851
+ folder: path.relative(this.settings.getSongsFolder(), folderPath) || '.',
852
+ format: 'cdg-pair',
853
+ ...metadata,
854
+ });
855
+ }
856
+ // If no CDG file, don't add this MP3 to the library
857
+ }
858
+ // CDG files without matching MP3 are also not added
859
+ } catch (error) {
860
+ console.error('❌ Error scanning folder:', folderPath, error);
861
+ }
862
+
863
+ return files;
864
+ }
865
+
866
+ extractCDGArchiveMetadata(archivePath) {
867
+ // yauzl already imported
868
+
869
+ return new Promise((resolve) => {
870
+ let hasCDG = false;
871
+ let hasMp3 = false;
872
+ let mp3FileName = null;
873
+
874
+ yauzl.open(archivePath, { lazyEntries: true }, (err, zipfile) => {
875
+ if (err) {
876
+ return resolve(null);
877
+ }
878
+
879
+ zipfile.readEntry();
880
+
881
+ zipfile.on('entry', (entry) => {
882
+ const lowerName = entry.fileName.toLowerCase();
883
+ if (lowerName.endsWith('.cdg')) {
884
+ hasCDG = true;
885
+ } else if (lowerName.endsWith('.mp3')) {
886
+ hasMp3 = true;
887
+ mp3FileName = entry.fileName;
888
+ }
889
+ zipfile.readEntry();
890
+ });
891
+
892
+ zipfile.on('end', async () => {
893
+ if (hasCDG && hasMp3) {
894
+ // Valid CDG archive - extract MP3 metadata
895
+ const metadata = await this.extractMp3MetadataFromArchive(archivePath, mp3FileName);
896
+ resolve(metadata);
897
+ } else {
898
+ resolve(null);
899
+ }
900
+ });
901
+ });
902
+ });
903
+ }
904
+
905
+ async extractMp3MetadataFromArchive(archivePath, mp3FileName) {
906
+ // yauzl already imported
907
+ const mm = await import('music-metadata');
908
+ // fs already imported
909
+ // os already imported
910
+ // path already imported
911
+
912
+ return new Promise((resolve) => {
913
+ const metadata = {
914
+ title: null,
915
+ artist: null,
916
+ album: null,
917
+ genre: null,
918
+ year: null,
919
+ duration: null,
920
+ };
921
+
922
+ yauzl.open(archivePath, { lazyEntries: true }, (err, zipfile) => {
923
+ if (err) {
924
+ return resolve(metadata);
925
+ }
926
+
927
+ zipfile.readEntry();
928
+
929
+ zipfile.on('entry', (entry) => {
930
+ if (entry.fileName === mp3FileName) {
931
+ zipfile.openReadStream(entry, (err, readStream) => {
932
+ if (err) {
933
+ zipfile.close();
934
+ return resolve(metadata);
935
+ }
936
+
937
+ // Create temp file for MP3
938
+ const tempPath = path.join(os.tmpdir(), `temp-${Date.now()}.mp3`);
939
+ const writeStream = fs.createWriteStream(tempPath);
940
+
941
+ readStream.pipe(writeStream);
942
+
943
+ writeStream.on('finish', async () => {
944
+ try {
945
+ const mmData = await mm.parseFile(tempPath);
946
+ if (mmData.common) {
947
+ metadata.title = mmData.common.title || null;
948
+ metadata.artist = mmData.common.artist || null;
949
+ metadata.album = mmData.common.album || null;
950
+ metadata.genre = mmData.common.genre ? mmData.common.genre[0] : null;
951
+ // Prefer full date (TDRC), fallback to year (TYER)
952
+ metadata.year =
953
+ mmData.common.date ||
954
+ (mmData.common.year ? String(mmData.common.year) : null);
955
+ }
956
+ if (mmData.format && mmData.format.duration) {
957
+ metadata.duration = mmData.format.duration;
958
+ }
959
+
960
+ // Fallback to filename parsing if no tags
961
+ if (!metadata.title || !metadata.artist) {
962
+ const baseName = path.basename(archivePath, path.extname(archivePath));
963
+ const dashIndex = baseName.indexOf(' - ');
964
+ if (dashIndex > 0 && dashIndex < baseName.length - 3) {
965
+ if (!metadata.artist)
966
+ metadata.artist = baseName.substring(0, dashIndex).trim();
967
+ if (!metadata.title)
968
+ metadata.title = baseName.substring(dashIndex + 3).trim();
969
+ } else {
970
+ if (!metadata.title) metadata.title = baseName;
971
+ if (!metadata.artist) metadata.artist = '';
972
+ }
973
+ }
974
+
975
+ // Ensure artist is never null
976
+ if (!metadata.artist) metadata.artist = '';
977
+ } catch (parseErr) {
978
+ console.warn('❌ Could not parse MP3 metadata:', parseErr.message);
979
+ // Fallback to filename parsing
980
+ const baseName = path.basename(archivePath, path.extname(archivePath));
981
+ const dashIndex = baseName.indexOf(' - ');
982
+ if (dashIndex > 0 && dashIndex < baseName.length - 3) {
983
+ metadata.artist = baseName.substring(0, dashIndex).trim();
984
+ metadata.title = baseName.substring(dashIndex + 3).trim();
985
+ } else {
986
+ metadata.title = baseName;
987
+ metadata.artist = '';
988
+ }
989
+ } finally {
990
+ // Clean up temp file
991
+ fs.unlink(tempPath, () => {});
992
+ zipfile.close();
993
+ resolve(metadata);
994
+ }
995
+ });
996
+ });
997
+ } else {
998
+ zipfile.readEntry();
999
+ }
1000
+ });
1001
+ });
1002
+ });
1003
+ }
1004
+
1005
+ async extractCDGPairMetadata(mp3Path, _cdgPath) {
1006
+ const mm = await import('music-metadata');
1007
+ // path already imported
1008
+
1009
+ const metadata = {
1010
+ title: null,
1011
+ artist: null,
1012
+ album: null,
1013
+ genre: null,
1014
+ year: null,
1015
+ duration: null,
1016
+ };
1017
+
1018
+ try {
1019
+ const mmData = await mm.parseFile(mp3Path);
1020
+ if (mmData.common) {
1021
+ metadata.title = mmData.common.title || null;
1022
+ metadata.artist = mmData.common.artist || null;
1023
+ metadata.album = mmData.common.album || null;
1024
+ metadata.genre = mmData.common.genre ? mmData.common.genre[0] : null;
1025
+ // Prefer full date (TDRC), fallback to year (TYER)
1026
+ metadata.year =
1027
+ mmData.common.date || (mmData.common.year ? String(mmData.common.year) : null);
1028
+ }
1029
+ if (mmData.format && mmData.format.duration) {
1030
+ metadata.duration = mmData.format.duration;
1031
+ }
1032
+
1033
+ // Fallback to filename parsing if no tags
1034
+ if (!metadata.title || !metadata.artist) {
1035
+ const baseName = path.basename(mp3Path, path.extname(mp3Path));
1036
+ // Try to parse "Artist - Title" format (safely)
1037
+ const dashIndex = baseName.indexOf(' - ');
1038
+ if (dashIndex > 0 && dashIndex < baseName.length - 3) {
1039
+ if (!metadata.artist) metadata.artist = baseName.substring(0, dashIndex).trim();
1040
+ if (!metadata.title) metadata.title = baseName.substring(dashIndex + 3).trim();
1041
+ } else {
1042
+ // No dash found, use entire basename as title
1043
+ if (!metadata.title) metadata.title = baseName;
1044
+ if (!metadata.artist) metadata.artist = '';
1045
+ }
1046
+ }
1047
+
1048
+ // Ensure artist is never null
1049
+ if (!metadata.artist) metadata.artist = '';
1050
+ } catch (err) {
1051
+ console.warn('❌ Could not parse MP3 metadata:', err.message);
1052
+ // Fallback to filename parsing
1053
+ const baseName = path.basename(mp3Path, path.extname(mp3Path));
1054
+ const dashIndex = baseName.indexOf(' - ');
1055
+ if (dashIndex > 0 && dashIndex < baseName.length - 3) {
1056
+ metadata.artist = baseName.substring(0, dashIndex).trim();
1057
+ metadata.title = baseName.substring(dashIndex + 3).trim();
1058
+ } else {
1059
+ metadata.title = baseName;
1060
+ metadata.artist = '';
1061
+ }
1062
+ }
1063
+
1064
+ return metadata;
1065
+ }
1066
+
1067
+ async extractM4AMetadata(m4aFilePath) {
1068
+ const mm = await import('music-metadata');
1069
+ // path already imported
1070
+
1071
+ const metadata = {
1072
+ title: null,
1073
+ artist: null,
1074
+ album: null,
1075
+ genre: null,
1076
+ year: null,
1077
+ key: null,
1078
+ duration: null,
1079
+ hasKaraoke: false,
1080
+ stems: [],
1081
+ stemCount: 0,
1082
+ tags: [],
1083
+ };
1084
+
1085
+ try {
1086
+ const mmData = await mm.parseFile(m4aFilePath);
1087
+
1088
+ // Extract standard MP4 metadata
1089
+ if (mmData.common) {
1090
+ metadata.title = mmData.common.title || null;
1091
+ metadata.artist = mmData.common.artist || null;
1092
+ metadata.album = mmData.common.album || null;
1093
+ metadata.genre = mmData.common.genre ? mmData.common.genre[0] : null;
1094
+ metadata.year =
1095
+ mmData.common.date || (mmData.common.year ? String(mmData.common.year) : null);
1096
+ // Key can be in common.key or native tags as initialkey
1097
+ metadata.key = mmData.common.key || null;
1098
+ }
1099
+ // Check native iTunes tags for initialkey if not found in common
1100
+ if (!metadata.key && mmData.native?.['iTunes']) {
1101
+ const keyTag = mmData.native['iTunes'].find(
1102
+ (t) => t.id === '----:com.apple.iTunes:initialkey' || t.id === 'initialkey'
1103
+ );
1104
+ if (keyTag) {
1105
+ metadata.key = keyTag.value;
1106
+ }
1107
+ }
1108
+ if (mmData.format && mmData.format.duration) {
1109
+ metadata.duration = mmData.format.duration;
1110
+ }
1111
+
1112
+ // Check for stem atom using m4a-stems (source of truth for audio tracks)
1113
+ try {
1114
+ const stemData = await M4AAtoms.readNiStemsMetadata(m4aFilePath);
1115
+ if (stemData && stemData.stems) {
1116
+ metadata.stems = stemData.stems.map((stem) => stem.name);
1117
+ metadata.stemCount = stemData.stems.length;
1118
+ }
1119
+ } catch {
1120
+ // No stem atom - not a stem file
1121
+ }
1122
+
1123
+ // Check for kara atom using m4a-stems (lyrics and karaoke data)
1124
+ try {
1125
+ const karaData = await M4AAtoms.readKaraAtom(m4aFilePath);
1126
+
1127
+ if (karaData && karaData.lines && karaData.lines.length > 0) {
1128
+ metadata.hasKaraoke = true;
1129
+
1130
+ // Extract tags if available
1131
+ if (karaData.tags && Array.isArray(karaData.tags)) {
1132
+ metadata.tags = karaData.tags;
1133
+ }
1134
+ }
1135
+ } catch {
1136
+ // No kara atom or error reading it - not a karaoke file
1137
+ console.log(`No kara atom in ${m4aFilePath}`);
1138
+ }
1139
+
1140
+ // Fallback to filename parsing if no tags
1141
+ if (!metadata.title || !metadata.artist) {
1142
+ const baseName = path.basename(m4aFilePath, path.extname(m4aFilePath));
1143
+ // Remove .stem suffix if present
1144
+ const cleanName = baseName.replace(/\.stem$/i, '');
1145
+ const dashIndex = cleanName.indexOf(' - ');
1146
+ if (dashIndex > 0 && dashIndex < cleanName.length - 3) {
1147
+ if (!metadata.artist) metadata.artist = cleanName.substring(0, dashIndex).trim();
1148
+ if (!metadata.title) metadata.title = cleanName.substring(dashIndex + 3).trim();
1149
+ } else {
1150
+ if (!metadata.title) metadata.title = cleanName;
1151
+ if (!metadata.artist) metadata.artist = '';
1152
+ }
1153
+ }
1154
+
1155
+ // Ensure artist is never null
1156
+ if (!metadata.artist) metadata.artist = '';
1157
+ } catch (err) {
1158
+ console.warn('❌ Could not parse M4A metadata:', err.message);
1159
+ // Fallback to filename parsing
1160
+ const baseName = path.basename(m4aFilePath, path.extname(m4aFilePath));
1161
+ const cleanName = baseName.replace(/\.stem$/i, '');
1162
+ const dashIndex = cleanName.indexOf(' - ');
1163
+ if (dashIndex > 0 && dashIndex < cleanName.length - 3) {
1164
+ metadata.artist = cleanName.substring(0, dashIndex).trim();
1165
+ metadata.title = cleanName.substring(dashIndex + 3).trim();
1166
+ } else {
1167
+ metadata.title = cleanName;
1168
+ metadata.artist = '';
1169
+ }
1170
+ }
1171
+
1172
+ return metadata;
1173
+ }
1174
+
1175
+ async loadKaiFile(filePath, queueItemId = null) {
1176
+ // Detect format and load accordingly
1177
+ const format = await this.detectSongFormat(filePath);
1178
+
1179
+ if (format.type === 'cdg') {
1180
+ return this.loadCDGFile(filePath, format.cdgPath, format.format, queueItemId);
1181
+ }
1182
+
1183
+ if (format.type === 'm4a') {
1184
+ return this.loadM4AFile(filePath, queueItemId);
1185
+ }
1186
+
1187
+ // Unsupported format
1188
+ console.error('Unsupported file format:', filePath);
1189
+ return {
1190
+ success: false,
1191
+ error: `Unsupported file format: ${path.extname(filePath)}`,
1192
+ };
1193
+ }
1194
+
1195
+ detectSongFormat(filePath) {
1196
+ const lowerPath = filePath.toLowerCase();
1197
+
1198
+ // Check for M4A/MP4 format (hasKaraoke check filters non-karaoke files)
1199
+ if (lowerPath.endsWith('.m4a') || lowerPath.endsWith('.mp4')) {
1200
+ return { type: 'm4a', format: 'm4a-stems', cdgPath: null };
1201
+ }
1202
+
1203
+ // Check for CDG archive (.kar or .zip)
1204
+ if (lowerPath.endsWith('.kar') || lowerPath.endsWith('.zip')) {
1205
+ return { type: 'cdg', format: 'cdg-archive', cdgPath: null };
1206
+ }
1207
+
1208
+ // Check for CDG pair (MP3 with matching CDG file)
1209
+ if (lowerPath.endsWith('.mp3')) {
1210
+ const basePath = filePath.substring(0, filePath.length - 4);
1211
+ const cdgPath = basePath + '.cdg';
1212
+
1213
+ if (fs.existsSync(cdgPath)) {
1214
+ return { type: 'cdg', format: 'cdg-pair', cdgPath };
1215
+ }
1216
+ }
1217
+
1218
+ // Unsupported format
1219
+ return { type: 'unsupported', format: 'unsupported', cdgPath: null };
1220
+ }
1221
+
1222
+ async loadCDGFile(mp3Path, cdgPath, format, queueItemId = null) {
1223
+ try {
1224
+ console.log('💿 Loading CDG file:', { mp3Path, cdgPath, format, queueItemId });
1225
+
1226
+ // Get requester from queue if queueItemId is provided
1227
+ let requester = 'KJ';
1228
+ if (queueItemId) {
1229
+ const queueItem = this.appState.getQueue().find((item) => item.id === queueItemId);
1230
+ if (queueItem) {
1231
+ requester = queueItem.requester || queueItem.singer || 'KJ';
1232
+ }
1233
+ }
1234
+
1235
+ const cdgData = await CDGLoader.load(mp3Path, cdgPath, format);
1236
+
1237
+ // TODO: Load CDG into audio engine (different path than KAI)
1238
+ // For now, just set current song and notify renderer
1239
+ // Add requester to cdgData so it's available in renderer
1240
+ cdgData.requester = requester;
1241
+
1242
+ this.currentSong = cdgData;
1243
+
1244
+ // Update AppState with current song info
1245
+ // Set isLoading: true initially, will be cleared when song fully loads
1246
+ const songData = {
1247
+ path: mp3Path,
1248
+ title: cdgData.metadata?.title || 'Unknown',
1249
+ artist: cdgData.metadata?.artist || 'Unknown',
1250
+ duration: cdgData.metadata?.duration || 0,
1251
+ requester: requester,
1252
+ isLoading: true, // Song is being loaded
1253
+ format: format, // Format for display icon (cdg-pair, cdg-archive, etc)
1254
+ queueItemId: queueItemId, // Track which queue item (for duplicate songs)
1255
+ };
1256
+ this.appState.setCurrentSong(songData);
1257
+
1258
+ console.log('💿 CDG loaded, sending to renderer');
1259
+ this.sendToRenderer('song:loaded', cdgData.metadata || {});
1260
+ this.sendToRenderer('song:data', cdgData);
1261
+
1262
+ // Broadcast song loaded to web clients (use songData, not cdgData!)
1263
+ if (this.webServer) {
1264
+ this.webServer.broadcastSongLoaded(songData);
1265
+ }
1266
+
1267
+ // Notify queue manager
1268
+ setTimeout(() => {
1269
+ this.sendToRenderer('queue:songStarted', mp3Path);
1270
+ }, 100);
1271
+
1272
+ return {
1273
+ success: true,
1274
+ metadata: cdgData.metadata,
1275
+ format: 'cdg',
1276
+ };
1277
+ } catch (error) {
1278
+ console.error('Failed to load CDG file:', error);
1279
+ return {
1280
+ success: false,
1281
+ error: error.message,
1282
+ };
1283
+ }
1284
+ }
1285
+
1286
+ async loadM4AFile(m4aPath, queueItemId = null) {
1287
+ try {
1288
+ console.log('🎵 Loading M4A file:', { m4aPath, queueItemId });
1289
+
1290
+ // Get requester from queue if queueItemId is provided
1291
+ let requester = 'KJ';
1292
+ if (queueItemId) {
1293
+ const queueItem = this.appState.getQueue().find((item) => item.id === queueItemId);
1294
+ if (queueItem) {
1295
+ requester = queueItem.requester || queueItem.singer || 'KJ';
1296
+ }
1297
+ }
1298
+
1299
+ const m4aData = await M4ALoader.load(m4aPath);
1300
+
1301
+ // Add original file path to the song data
1302
+ m4aData.originalFilePath = m4aPath;
1303
+ // Add requester to m4aData so it's available in renderer
1304
+ m4aData.requester = requester;
1305
+
1306
+ // Load into audio engine (uses same path as KAI format)
1307
+ if (this.audioEngine) {
1308
+ await this.audioEngine.loadSong(m4aData);
1309
+ }
1310
+
1311
+ this.currentSong = m4aData;
1312
+
1313
+ // Update AppState with current song info
1314
+ // Set isLoading: true initially, will be cleared when song fully loads
1315
+ const songData = {
1316
+ path: m4aPath,
1317
+ title: m4aData.metadata?.title || 'Unknown',
1318
+ artist: m4aData.metadata?.artist || 'Unknown',
1319
+ duration: m4aData.metadata?.duration || 0,
1320
+ requester: requester,
1321
+ isLoading: true, // Song is being loaded
1322
+ format: 'm4a-stems', // Format for display icon
1323
+ queueItemId: queueItemId, // Track which queue item (for duplicate songs)
1324
+ };
1325
+ this.appState.setCurrentSong(songData);
1326
+
1327
+ console.log('🎵 M4A loaded, sending to renderer');
1328
+ this.sendToRenderer('song:loaded', m4aData.metadata || {});
1329
+ this.sendToRenderer('song:data', m4aData);
1330
+
1331
+ // Broadcast song loaded to web clients via Socket.IO (use songData, not m4aData!)
1332
+ if (this.webServer) {
1333
+ this.webServer.broadcastSongLoaded(songData);
1334
+ }
1335
+
1336
+ // Notify queue manager that this song is now current
1337
+ setTimeout(() => {
1338
+ this.sendToRenderer('queue:songStarted', m4aPath);
1339
+ }, 100);
1340
+
1341
+ return {
1342
+ success: true,
1343
+ metadata: m4aData.metadata,
1344
+ meta: m4aData.meta,
1345
+ stems: m4aData.audio.sources,
1346
+ };
1347
+ } catch (error) {
1348
+ console.error('Failed to load M4A file:', error);
1349
+ return {
1350
+ success: false,
1351
+ error: error.message,
1352
+ };
1353
+ }
1354
+ }
1355
+
1356
+ async checkSongsFolder() {
1357
+ const songsFolder = this.settings.getSongsFolder();
1358
+
1359
+ if (!songsFolder) {
1360
+ console.log('📁 No songs folder set, prompting user...');
1361
+ await this.promptForSongsFolder();
1362
+ } else {
1363
+ console.log('📁 Songs folder:', songsFolder);
1364
+ // Verify folder still exists
1365
+ if (!fs.existsSync(songsFolder)) {
1366
+ console.log('⚠️ Songs folder no longer exists, prompting for new one...');
1367
+ await this.promptForSongsFolder();
1368
+ } else {
1369
+ // Trigger library scan on startup in background
1370
+ console.log('📚 Starting library scan...');
1371
+ this.scanLibraryInBackground(songsFolder);
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ async scanLibraryInBackground(songsFolder) {
1377
+ try {
1378
+ // Try to load from cache first
1379
+ const cacheFile = path.join(app.getPath('userData'), 'library-cache.json');
1380
+ let useCache = false;
1381
+
1382
+ try {
1383
+ const cacheData = JSON.parse(await fsPromises.readFile(cacheFile, 'utf8'));
1384
+ // Check if cache is for the same folder
1385
+ if (cacheData.songsFolder === songsFolder) {
1386
+ console.log(`📂 Found library cache with ${cacheData.files.length} songs`);
1387
+
1388
+ // Load from cache
1389
+ const files = cacheData.files;
1390
+
1391
+ // Store in main process
1392
+ this.cachedLibrary = files;
1393
+
1394
+ // Update web server cache
1395
+ if (this.webServer) {
1396
+ this.webServer.cachedSongs = files;
1397
+ this.webServer.songsCacheTime = Date.now();
1398
+ this.webServer.fuse = null;
1399
+ }
1400
+
1401
+ // Notify renderer
1402
+ this.sendToRenderer('library:scanComplete', { count: files.length });
1403
+ console.log(`✅ Library loaded from cache: ${files.length} songs`);
1404
+ useCache = true;
1405
+ }
1406
+ } catch {
1407
+ // Cache doesn't exist or is invalid, will scan
1408
+ console.log('📚 No valid cache found, scanning library...');
1409
+ }
1410
+
1411
+ if (useCache) return;
1412
+
1413
+ // First, quickly count all files
1414
+ console.log('📊 Counting files...');
1415
+ const allFiles = await this.findAllKaiFiles(songsFolder);
1416
+ const totalFiles = allFiles.length;
1417
+ console.log(`📊 Found ${totalFiles} files to process`);
1418
+
1419
+ // Notify renderer of total count
1420
+ this.sendToRenderer('library:scanProgress', { current: 0, total: totalFiles });
1421
+
1422
+ // Now process files with metadata extraction and progress updates
1423
+ // Pass null for progressCallback since this.sendToRenderer() is called directly in the method
1424
+ const files = await this.scanForKaiFilesWithProgress(songsFolder, totalFiles, null);
1425
+ console.log(`✅ Library scan complete: ${files.length} songs found`);
1426
+
1427
+ // Store in main process
1428
+ this.cachedLibrary = files;
1429
+
1430
+ // Cache the results for web server
1431
+ if (this.webServer) {
1432
+ this.webServer.cachedSongs = files;
1433
+ this.webServer.songsCacheTime = Date.now();
1434
+ this.webServer.fuse = null; // Reset Fuse.js - will rebuild on next search
1435
+ }
1436
+
1437
+ // Save to disk cache
1438
+ try {
1439
+ await fsPromises.writeFile(
1440
+ cacheFile,
1441
+ JSON.stringify({
1442
+ songsFolder,
1443
+ files,
1444
+ cachedAt: new Date().toISOString(),
1445
+ }),
1446
+ 'utf8'
1447
+ );
1448
+ console.log('💾 Library cache saved to disk');
1449
+ } catch (err) {
1450
+ console.error('Failed to save library cache:', err);
1451
+ }
1452
+
1453
+ // Notify renderer that library is ready
1454
+ this.sendToRenderer('library:scanComplete', { count: files.length });
1455
+ } catch (error) {
1456
+ console.error('❌ Failed to scan library:', error);
1457
+ }
1458
+ }
1459
+
1460
+ async scanFilesystemForSync(folderPath) {
1461
+ // Quickly scan filesystem and return file info without parsing metadata
1462
+ const fileInfos = [];
1463
+ const processedPairs = new Set();
1464
+
1465
+ async function scan(dir) {
1466
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
1467
+
1468
+ for (const entry of entries) {
1469
+ if (entry.name.startsWith('._') || entry.name === '.DS_Store') {
1470
+ continue;
1471
+ }
1472
+
1473
+ const fullPath = path.join(dir, entry.name);
1474
+
1475
+ if (entry.isDirectory()) {
1476
+ // Recursively scan subdirectories - intentional sequential processing
1477
+ // eslint-disable-next-line no-await-in-loop
1478
+ await scan(fullPath);
1479
+ } else {
1480
+ const lowerName = entry.name.toLowerCase();
1481
+
1482
+ // CDG archives
1483
+ if (
1484
+ lowerName.endsWith('.kar') ||
1485
+ (lowerName.endsWith('.zip') && !processedPairs.has(fullPath))
1486
+ ) {
1487
+ fileInfos.push({ path: fullPath, type: 'archive' });
1488
+ }
1489
+ // CDG+MP3 pairs - check if both exist
1490
+ else if (lowerName.endsWith('.cdg')) {
1491
+ const baseName = fullPath.slice(0, -4);
1492
+ const mp3Path = baseName + '.mp3';
1493
+
1494
+ try {
1495
+ // Sequential file I/O for CDG pair verification
1496
+ // eslint-disable-next-line no-await-in-loop
1497
+ await fsPromises.access(mp3Path);
1498
+ // Only add if we haven't seen this pair
1499
+ if (!processedPairs.has(fullPath)) {
1500
+ // Use MP3 path as primary key to match scanForKaiFilesWithProgress
1501
+ fileInfos.push({ path: mp3Path, type: 'cdg-pair', cdgPath: fullPath });
1502
+ processedPairs.add(fullPath);
1503
+ processedPairs.add(mp3Path);
1504
+ }
1505
+ } catch {
1506
+ // No paired MP3, skip this CDG
1507
+ }
1508
+ }
1509
+ // M4A/MP4 files
1510
+ else if (lowerName.endsWith('.m4a') || lowerName.endsWith('.mp4')) {
1511
+ fileInfos.push({ path: fullPath, type: 'm4a' });
1512
+ }
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ await scan(folderPath);
1518
+ return fileInfos;
1519
+ }
1520
+
1521
+ async parseMetadataWithProgress(fileInfos, totalFiles, progressOffset = 0) {
1522
+ // Parse metadata for new files
1523
+ const files = [];
1524
+ const newFilesCount = fileInfos.length;
1525
+
1526
+ for (let i = 0; i < newFilesCount; i++) {
1527
+ const fileInfo = fileInfos[i];
1528
+ const fullPath = fileInfo.path;
1529
+
1530
+ try {
1531
+ if (fileInfo.type === 'archive') {
1532
+ // Sequential metadata extraction for CDG archives
1533
+ // eslint-disable-next-line no-await-in-loop
1534
+ const metadata = await this.extractCDGArchiveMetadata(fullPath);
1535
+ if (metadata) {
1536
+ files.push({
1537
+ name: fullPath,
1538
+ path: fullPath,
1539
+ file: fullPath,
1540
+ format: 'cdg-archive',
1541
+ title: metadata.title,
1542
+ artist: metadata.artist,
1543
+ album: metadata.album,
1544
+ genre: metadata.genre,
1545
+ year: metadata.year,
1546
+ duration: metadata.duration,
1547
+ });
1548
+ }
1549
+ } else if (fileInfo.type === 'cdg-pair') {
1550
+ // Sequential metadata extraction for CDG pairs
1551
+ // eslint-disable-next-line no-await-in-loop
1552
+ const metadata = await this.extractCDGPairMetadata(fullPath, fileInfo.cdgPath);
1553
+ if (metadata) {
1554
+ files.push({
1555
+ name: fullPath,
1556
+ path: fullPath,
1557
+ file: fullPath,
1558
+ format: 'cdg-pair',
1559
+ title: metadata.title,
1560
+ artist: metadata.artist,
1561
+ album: metadata.album,
1562
+ genre: metadata.genre,
1563
+ year: metadata.year,
1564
+ duration: metadata.duration,
1565
+ cdgPath: fileInfo.cdgPath,
1566
+ });
1567
+ }
1568
+ } else if (fileInfo.type === 'm4a') {
1569
+ // Sequential metadata extraction for M4A files
1570
+ // eslint-disable-next-line no-await-in-loop
1571
+ const metadata = await this.extractM4AMetadata(fullPath);
1572
+ if (metadata && metadata.hasKaraoke) {
1573
+ files.push({
1574
+ name: fullPath,
1575
+ path: fullPath,
1576
+ file: fullPath,
1577
+ format: 'm4a-stems',
1578
+ title: metadata.title,
1579
+ artist: metadata.artist,
1580
+ album: metadata.album,
1581
+ genre: metadata.genre,
1582
+ year: metadata.year,
1583
+ key: metadata.key,
1584
+ duration: metadata.duration,
1585
+ stems: metadata.stems,
1586
+ stemCount: metadata.stemCount,
1587
+ tags: metadata.tags,
1588
+ });
1589
+ }
1590
+ }
1591
+ } catch (err) {
1592
+ console.error(`Error processing ${fullPath}:`, err);
1593
+ }
1594
+
1595
+ // Calculate progress
1596
+ const fileProgress = ((i + 1) / newFilesCount) * (1 - progressOffset);
1597
+ const currentProgress = Math.floor((progressOffset + fileProgress) * totalFiles);
1598
+ this.sendToRenderer('library:scanProgress', {
1599
+ current: currentProgress,
1600
+ total: totalFiles,
1601
+ });
1602
+ }
1603
+
1604
+ return files;
1605
+ }
1606
+
1607
+ async findAllKaiFiles(folderPath) {
1608
+ const allFiles = [];
1609
+ const processedPairs = new Set();
1610
+
1611
+ async function scan(dir) {
1612
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
1613
+
1614
+ for (const entry of entries) {
1615
+ // Skip macOS resource fork files and .DS_Store
1616
+ if (entry.name.startsWith('._') || entry.name === '.DS_Store') {
1617
+ continue;
1618
+ }
1619
+
1620
+ const fullPath = path.join(dir, entry.name);
1621
+
1622
+ if (entry.isDirectory()) {
1623
+ // Recursively scan subdirectories - intentional sequential processing
1624
+ // eslint-disable-next-line no-await-in-loop
1625
+ await scan(fullPath);
1626
+ } else {
1627
+ const lowerName = entry.name.toLowerCase();
1628
+
1629
+ // CDG archives
1630
+ if (
1631
+ lowerName.endsWith('.kar') ||
1632
+ (lowerName.endsWith('.zip') && !processedPairs.has(fullPath))
1633
+ ) {
1634
+ allFiles.push(fullPath);
1635
+ }
1636
+ // CDG+MP3 pairs - only count once, return MP3 path
1637
+ else if (lowerName.endsWith('.cdg')) {
1638
+ const baseName = fullPath.slice(0, -4);
1639
+ const mp3Path = baseName + '.mp3';
1640
+
1641
+ // Check if paired MP3 exists
1642
+ try {
1643
+ // Sequential file I/O for MP3 pair verification
1644
+ // eslint-disable-next-line no-await-in-loop
1645
+ await fsPromises.access(mp3Path);
1646
+ // Only add if we haven't seen this pair
1647
+ if (!processedPairs.has(fullPath)) {
1648
+ allFiles.push(mp3Path); // Return MP3 path to match cache format
1649
+ processedPairs.add(fullPath);
1650
+ processedPairs.add(mp3Path); // Mark MP3 as processed too
1651
+ }
1652
+ } catch {
1653
+ // No paired MP3, skip this CDG
1654
+ }
1655
+ }
1656
+ // M4A/MP4 stem files
1657
+ else if (lowerName.endsWith('.m4a') || lowerName.endsWith('.mp4')) {
1658
+ allFiles.push(fullPath);
1659
+ }
1660
+ }
1661
+ }
1662
+ }
1663
+
1664
+ await scan(folderPath);
1665
+ return allFiles;
1666
+ }
1667
+
1668
+ async scanFilesWithProgress(filePaths, totalFiles, progressOffset = 0) {
1669
+ const files = [];
1670
+ const newFilesCount = filePaths.length;
1671
+
1672
+ for (let i = 0; i < newFilesCount; i++) {
1673
+ const fullPath = filePaths[i];
1674
+ const lowerName = fullPath.toLowerCase();
1675
+
1676
+ try {
1677
+ // CDG archives
1678
+ if (lowerName.endsWith('.kar') || lowerName.endsWith('.zip')) {
1679
+ // Sequential CDG archive metadata extraction
1680
+ // eslint-disable-next-line no-await-in-loop
1681
+ const metadata = await this.extractCDGArchiveMetadata(fullPath);
1682
+ if (metadata) {
1683
+ files.push({
1684
+ name: fullPath,
1685
+ path: fullPath,
1686
+ file: fullPath,
1687
+ format: 'cdg-archive',
1688
+ title: metadata.title,
1689
+ artist: metadata.artist,
1690
+ album: metadata.album,
1691
+ genre: metadata.genre,
1692
+ year: metadata.year,
1693
+ duration: metadata.duration,
1694
+ });
1695
+ }
1696
+ }
1697
+ // CDG+MP3 pairs
1698
+ else if (lowerName.endsWith('.cdg')) {
1699
+ const baseName = fullPath.slice(0, -4);
1700
+ const mp3Path = baseName + '.mp3';
1701
+
1702
+ // Verify MP3 file exists
1703
+ try {
1704
+ // Sequential file I/O for MP3 verification
1705
+ // eslint-disable-next-line no-await-in-loop
1706
+ await fsPromises.access(mp3Path);
1707
+ // Sequential CDG pair metadata extraction
1708
+ // eslint-disable-next-line no-await-in-loop
1709
+ const metadata = await this.extractCDGPairMetadata(mp3Path, fullPath);
1710
+ if (metadata) {
1711
+ files.push({
1712
+ name: mp3Path,
1713
+ path: mp3Path,
1714
+ file: mp3Path,
1715
+ format: 'cdg-pair',
1716
+ title: metadata.title,
1717
+ artist: metadata.artist,
1718
+ album: metadata.album,
1719
+ genre: metadata.genre,
1720
+ year: metadata.year,
1721
+ duration: metadata.duration,
1722
+ cdgPath: fullPath,
1723
+ });
1724
+ }
1725
+ } catch {
1726
+ // MP3 file doesn't exist, skip this CDG
1727
+ console.warn(`⚠️ Skipping CDG file without MP3: ${fullPath}`);
1728
+ }
1729
+ }
1730
+ } catch (err) {
1731
+ console.error(`Error processing ${fullPath}:`, err);
1732
+ }
1733
+
1734
+ // Calculate progress: progressOffset (10%) + current file progress (0-90%)
1735
+ const fileProgress = ((i + 1) / newFilesCount) * (1 - progressOffset);
1736
+ const currentProgress = Math.floor((progressOffset + fileProgress) * totalFiles);
1737
+ this.sendToRenderer('library:scanProgress', {
1738
+ current: currentProgress,
1739
+ total: totalFiles,
1740
+ });
1741
+ }
1742
+
1743
+ return files;
1744
+ }
1745
+
1746
+ async scanForKaiFilesWithProgress(folderPath, totalFiles, progressCallback) {
1747
+ let processedCount = 0;
1748
+ const files = [];
1749
+ const processedPaths = new Set();
1750
+
1751
+ let lastProgressReport = Date.now();
1752
+ const reportProgress = (force = false) => {
1753
+ const now = Date.now();
1754
+ // Throttle to max once per second to avoid overwhelming the renderer
1755
+ if (force || now - lastProgressReport >= 1000) {
1756
+ const progressData = {
1757
+ current: processedCount,
1758
+ total: totalFiles,
1759
+ };
1760
+
1761
+ // Send to renderer
1762
+ this.sendToRenderer('library:scanProgress', progressData);
1763
+
1764
+ // Call progress callback if provided (for libraryService)
1765
+ if (progressCallback) {
1766
+ progressCallback(progressData);
1767
+ }
1768
+
1769
+ lastProgressReport = now;
1770
+ }
1771
+ };
1772
+
1773
+ async function scanDir(dir, self) {
1774
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
1775
+
1776
+ for (const entry of entries) {
1777
+ const fullPath = path.join(dir, entry.name);
1778
+
1779
+ if (entry.isDirectory()) {
1780
+ // Recursively scan subdirectories - intentional sequential processing
1781
+ // eslint-disable-next-line no-await-in-loop
1782
+ await scanDir(fullPath, self);
1783
+ } else {
1784
+ const lowerName = entry.name.toLowerCase();
1785
+
1786
+ // CDG archives
1787
+ if (
1788
+ (lowerName.endsWith('.kar') || lowerName.endsWith('.zip')) &&
1789
+ !processedPaths.has(fullPath)
1790
+ ) {
1791
+ processedPaths.add(fullPath);
1792
+ // Sequential CDG archive metadata extraction with progress reporting
1793
+ // eslint-disable-next-line no-await-in-loop
1794
+ const metadata = await self.extractCDGArchiveMetadata(fullPath);
1795
+ if (metadata) {
1796
+ files.push({
1797
+ name: fullPath,
1798
+ path: fullPath,
1799
+ format: 'cdg-archive',
1800
+ title: metadata.title,
1801
+ artist: metadata.artist,
1802
+ album: metadata.album,
1803
+ genre: metadata.genre,
1804
+ year: metadata.year,
1805
+ duration: metadata.duration,
1806
+ });
1807
+ }
1808
+ processedCount++;
1809
+ reportProgress();
1810
+ }
1811
+ // CDG+MP3 pairs
1812
+ else if (lowerName.endsWith('.cdg') && !processedPaths.has(fullPath)) {
1813
+ const baseName = fullPath.slice(0, -4);
1814
+ const mp3Path = baseName + '.mp3';
1815
+
1816
+ // Sequential file I/O for MP3 verification with progress reporting
1817
+ // eslint-disable-next-line no-await-in-loop
1818
+ const mp3Exists = await fsPromises
1819
+ .access(mp3Path)
1820
+ .then(() => true)
1821
+ .catch(() => false);
1822
+
1823
+ if (mp3Exists) {
1824
+ processedPaths.add(fullPath);
1825
+ processedPaths.add(mp3Path);
1826
+
1827
+ // Sequential CDG pair metadata extraction with progress reporting
1828
+ // eslint-disable-next-line no-await-in-loop
1829
+ const metadata = await self.extractCDGPairMetadata(mp3Path);
1830
+ files.push({
1831
+ name: mp3Path,
1832
+ path: mp3Path,
1833
+ format: 'cdg-pair',
1834
+ title: metadata.title,
1835
+ artist: metadata.artist,
1836
+ album: metadata.album,
1837
+ genre: metadata.genre,
1838
+ year: metadata.year,
1839
+ duration: metadata.duration,
1840
+ cdgPath: fullPath,
1841
+ });
1842
+ processedCount++;
1843
+ reportProgress();
1844
+ }
1845
+ }
1846
+ // M4A/MP4 stem files
1847
+ else if (
1848
+ (lowerName.endsWith('.m4a') || lowerName.endsWith('.mp4')) &&
1849
+ !processedPaths.has(fullPath)
1850
+ ) {
1851
+ processedPaths.add(fullPath);
1852
+ // Sequential M4A metadata extraction with progress reporting
1853
+ // eslint-disable-next-line no-await-in-loop
1854
+ const metadata = await self.extractM4AMetadata(fullPath);
1855
+ if (metadata && metadata.hasKaraoke) {
1856
+ files.push({
1857
+ name: fullPath,
1858
+ path: fullPath,
1859
+ format: 'm4a-stems',
1860
+ title: metadata.title,
1861
+ artist: metadata.artist,
1862
+ album: metadata.album,
1863
+ genre: metadata.genre,
1864
+ year: metadata.year,
1865
+ key: metadata.key,
1866
+ duration: metadata.duration,
1867
+ stems: metadata.stems,
1868
+ stemCount: metadata.stemCount,
1869
+ tags: metadata.tags,
1870
+ });
1871
+ }
1872
+ processedCount++;
1873
+ reportProgress();
1874
+ }
1875
+ }
1876
+ }
1877
+ }
1878
+
1879
+ await scanDir(folderPath, this);
1880
+ reportProgress(true); // Final progress update (force)
1881
+ return files;
1882
+ }
1883
+
1884
+ /**
1885
+ * Set songs folder and trigger auto-scan
1886
+ * Called by promptForSongsFolder and libraryHandlers
1887
+ */
1888
+ setSongsFolderAndScan(folder) {
1889
+ this.settings.setSongsFolder(folder);
1890
+ console.log('📁 Songs folder set to:', folder);
1891
+
1892
+ // Notify renderer about the new library
1893
+ this.sendToRenderer('library:folderSet', folder);
1894
+
1895
+ // Auto-scan the library when folder is set or changed
1896
+ this.scanLibraryInBackground(folder);
1897
+ }
1898
+
1899
+ async promptForSongsFolder() {
1900
+ const result = await dialog.showMessageBox(this.mainWindow, {
1901
+ type: 'info',
1902
+ title: 'Set Songs Library Folder',
1903
+ message: 'Choose a folder where your KAI music files are stored',
1904
+ detail: 'This will be your songs library that appears in the app.',
1905
+ buttons: ['Choose Folder', 'Skip for Now'],
1906
+ });
1907
+
1908
+ if (result.response === 0) {
1909
+ const folderResult = await dialog.showOpenDialog(this.mainWindow, {
1910
+ title: 'Select Songs Library Folder',
1911
+ properties: ['openDirectory'],
1912
+ buttonLabel: 'Select Folder',
1913
+ });
1914
+
1915
+ if (!folderResult.canceled && folderResult.filePaths.length > 0) {
1916
+ await this.setSongsFolderAndScan(folderResult.filePaths[0]);
1917
+ }
1918
+ }
1919
+ }
1920
+
1921
+ sendToRenderer(channel, data) {
1922
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
1923
+ this.mainWindow.webContents.send(channel, data);
1924
+ }
1925
+ }
1926
+
1927
+ sendToRendererAndWait(channel, ..._args) {
1928
+ return new Promise((resolve) => {
1929
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
1930
+ // Create a one-time listener for the response
1931
+ const responseChannel = `${channel}-response`;
1932
+
1933
+ const listener = (_event, data) => {
1934
+ clearTimeout(timeoutId);
1935
+ ipcMain.removeListener(responseChannel, listener);
1936
+ resolve(data);
1937
+ };
1938
+
1939
+ const timeoutId = setTimeout(() => {
1940
+ ipcMain.removeListener(responseChannel, listener);
1941
+ resolve(null);
1942
+ }, 5000);
1943
+
1944
+ ipcMain.once(responseChannel, listener);
1945
+
1946
+ // Send the request
1947
+ this.mainWindow.webContents.send(channel);
1948
+ } else {
1949
+ resolve(null);
1950
+ }
1951
+ });
1952
+ }
1953
+
1954
+ /**
1955
+ * Broadcast a settings change to renderer and web clients
1956
+ * @param {string} key - Settings key
1957
+ * @param {*} value - Settings value
1958
+ */
1959
+ broadcastSettingChange(key, value) {
1960
+ const channel = getBroadcastChannel(key);
1961
+
1962
+ // Send to renderer
1963
+ this.sendToRenderer(channel, value);
1964
+
1965
+ // Send to web clients via Socket.IO
1966
+ if (this.webServer?.io) {
1967
+ this.webServer.io.emit(channel, value);
1968
+ }
1969
+
1970
+ console.log(`📡 Settings broadcast: ${key} -> ${channel}`);
1971
+ }
1972
+
1973
+ // Web Server Integration Methods
1974
+ async initializeWebServer() {
1975
+ try {
1976
+ this.webServer = new WebServer(this);
1977
+ const port = await this.webServer.start(3069);
1978
+
1979
+ console.log(`🌐 Web server started at http://localhost:${port}`);
1980
+ console.log(`📱 Song requests available at: http://localhost:${port}`);
1981
+
1982
+ // Connect to Socket.IO server
1983
+ await this.connectToSocketServer(port);
1984
+
1985
+ // Start position broadcasting timer
1986
+ this.startPositionBroadcasting();
1987
+
1988
+ // Notify renderer about web server
1989
+ this.sendToRenderer('webServer:started', { port });
1990
+ } catch (error) {
1991
+ console.error('Failed to start web server:', error);
1992
+ // Don't fail the entire app if web server fails
1993
+ }
1994
+ }
1995
+
1996
+ connectToSocketServer(port) {
1997
+ try {
1998
+ this.socket = io(`http://localhost:${port}`);
1999
+
2000
+ this.socket.on('connect', () => {
2001
+ console.log('📡 Connected to Socket.IO server');
2002
+
2003
+ // Identify as electron app
2004
+ this.socket.emit('identify', { type: 'electron-app' });
2005
+ });
2006
+
2007
+ this.socket.on('disconnect', () => {
2008
+ console.log('📡 Disconnected from Socket.IO server');
2009
+ });
2010
+
2011
+ this.socket.on('song-request', (request) => {
2012
+ console.log('🎵 Song request received:', request);
2013
+
2014
+ // Notify renderer about new request
2015
+ this.sendToRenderer('songRequest:new', request);
2016
+ });
2017
+
2018
+ this.socket.on('request-approved', (request) => {
2019
+ console.log('✅ Request approved:', request);
2020
+
2021
+ // Notify renderer about approved request
2022
+ this.sendToRenderer('songRequest:approved', request);
2023
+ });
2024
+
2025
+ this.socket.on('request-rejected', (request) => {
2026
+ console.log('❌ Request rejected:', request);
2027
+
2028
+ // Notify renderer about rejected request
2029
+ this.sendToRenderer('songRequest:rejected', request);
2030
+ });
2031
+
2032
+ this.socket.on('connect_error', (error) => {
2033
+ console.error('Socket connection error:', error);
2034
+ });
2035
+
2036
+ this.socket.on('effect-control', (data) => {
2037
+ console.log('🎨 Effect control received:', data.action);
2038
+ this.handleEffectControl(data.action);
2039
+ });
2040
+
2041
+ this.socket.on('settings-update', (settings) => {
2042
+ console.log('🔧 Settings update received from server:', settings);
2043
+ this.handleSettingsUpdate(settings);
2044
+ });
2045
+ } catch (error) {
2046
+ console.error('Failed to connect to Socket.IO server:', error);
2047
+ }
2048
+ }
2049
+
2050
+ // Socket.IO helper methods
2051
+ broadcastQueueUpdate() {
2052
+ if (this.socket && this.socket.connected) {
2053
+ this.socket.emit('queue-updated', {
2054
+ queue: this.appState.getQueue(),
2055
+ currentSong: this.currentSong,
2056
+ });
2057
+ }
2058
+ }
2059
+
2060
+ broadcastPlayerState(state) {
2061
+ if (this.socket && this.socket.connected) {
2062
+ this.socket.emit('player-state', state);
2063
+ }
2064
+ }
2065
+
2066
+ broadcastSettingsChange(settings) {
2067
+ if (this.socket && this.socket.connected) {
2068
+ this.socket.emit('settings-changed', settings);
2069
+ }
2070
+ }
2071
+
2072
+ handleEffectControl(action) {
2073
+ // Send effect control command to renderer process
2074
+ if (action === 'previous') {
2075
+ this.sendToRenderer('effect:previous', {});
2076
+ console.log('🎨 Sent previous effect command to renderer');
2077
+ } else if (action === 'next') {
2078
+ this.sendToRenderer('effect:next', {});
2079
+ console.log('🎨 Sent next effect command to renderer');
2080
+ }
2081
+ }
2082
+
2083
+ handleSettingsUpdate(settings) {
2084
+ // Update webServer settings
2085
+ if (this.webServer) {
2086
+ // Update without triggering another broadcast to avoid loops
2087
+ this.webServer.settings = { ...this.webServer.settings, ...settings };
2088
+ }
2089
+
2090
+ // Send settings update to renderer to update UI
2091
+ this.sendToRenderer('settings:update', settings);
2092
+ console.log('🔧 Settings update sent to renderer');
2093
+ }
2094
+
2095
+ // Methods called by WebServer
2096
+ async getLibrarySongs() {
2097
+ const result = await libraryService.getLibrarySongs(this);
2098
+ return result.songs || [];
2099
+ }
2100
+
2101
+ getQueue() {
2102
+ return this.appState.getQueue();
2103
+ }
2104
+
2105
+ getCurrentSong() {
2106
+ // Return from AppState for consistency
2107
+ if (this.appState.state.currentSong) {
2108
+ return this.appState.state.currentSong;
2109
+ }
2110
+ // Fallback to legacy for compatibility
2111
+ return this.currentSong;
2112
+ }
2113
+
2114
+ async addSongToQueue(queueItem) {
2115
+ console.log('🎵 MAIN addSongToQueue called with:', queueItem);
2116
+
2117
+ // Use shared queueService
2118
+ const result = queueService.addSongToQueue(this.appState, queueItem);
2119
+
2120
+ if (!result.success) {
2121
+ console.error('❌ Failed to add song to queue:', result.error);
2122
+ throw new Error(result.error);
2123
+ }
2124
+
2125
+ console.log('🎵 Created new queue item:', result.queueItem);
2126
+
2127
+ // Update legacy songQueue for compatibility
2128
+ this.songQueue = result.queue;
2129
+
2130
+ // If queue was empty, automatically load and start playing the first song
2131
+ if (result.wasEmpty) {
2132
+ console.log(`🎵 Queue was empty, auto-loading "${result.queueItem.title}"`);
2133
+ try {
2134
+ // Use the returned queueItem which has the generated ID
2135
+ await this.loadKaiFile(result.queueItem.path, result.queueItem.id);
2136
+ console.log('✅ Successfully auto-loaded song from queue');
2137
+ } catch (error) {
2138
+ console.error('❌ Failed to auto-load song from queue:', error);
2139
+ }
2140
+ }
2141
+
2142
+ console.log(`➕ Added "${queueItem.title}" to queue (requested by ${queueItem.requester})`);
2143
+ return result;
2144
+ }
2145
+
2146
+ onSongRequest(request) {
2147
+ // Notify renderer about new song request
2148
+ this.sendToRenderer('songRequest:new', request);
2149
+ console.log(`🎤 New song request: "${request.song.title}" by ${request.requesterName}`);
2150
+ }
2151
+
2152
+ // Effects management methods for web server
2153
+ async getEffectsList() {
2154
+ try {
2155
+ return await this.sendToRendererAndWait('effects:getList');
2156
+ } catch (error) {
2157
+ console.error('Failed to get effects list:', error);
2158
+ return [];
2159
+ }
2160
+ }
2161
+
2162
+ async getCurrentEffect() {
2163
+ try {
2164
+ return await this.sendToRendererAndWait('effects:getCurrent');
2165
+ } catch (error) {
2166
+ console.error('Failed to get current effect:', error);
2167
+ return null;
2168
+ }
2169
+ }
2170
+
2171
+ async getDisabledEffects() {
2172
+ try {
2173
+ return await this.sendToRendererAndWait('effects:getDisabled');
2174
+ } catch (error) {
2175
+ console.error('Failed to get disabled effects:', error);
2176
+ return [];
2177
+ }
2178
+ }
2179
+
2180
+ selectEffect(effectName) {
2181
+ try {
2182
+ this.sendToRenderer('effects:select', effectName);
2183
+ return { success: true };
2184
+ } catch (error) {
2185
+ console.error('Failed to select effect:', error);
2186
+ throw error;
2187
+ }
2188
+ }
2189
+
2190
+ toggleEffect(effectName, enabled) {
2191
+ try {
2192
+ this.sendToRenderer('effects:toggle', { effectName, enabled });
2193
+ return { success: true };
2194
+ } catch (error) {
2195
+ console.error('Failed to toggle effect:', error);
2196
+ throw error;
2197
+ }
2198
+ }
2199
+
2200
+ // Removed duplicate playerNext() - see below for correct implementation
2201
+
2202
+ // clearQueue() moved below - uses AppState
2203
+
2204
+ // Removed duplicate getCurrentSong() - see line 1915 for the correct implementation (uses AppState)
2205
+
2206
+ // Web server management methods
2207
+ getWebServerPort() {
2208
+ return this.webServer ? this.webServer.getPort() : null;
2209
+ }
2210
+
2211
+ getWebServerSettings() {
2212
+ if (this.webServer) {
2213
+ const result = serverSettingsService.getServerSettings(this.webServer);
2214
+ return result.success ? result.settings : null;
2215
+ }
2216
+ return null;
2217
+ }
2218
+
2219
+ updateWebServerSettings(settings) {
2220
+ if (this.webServer) {
2221
+ return serverSettingsService.updateServerSettings(this.webServer, settings);
2222
+ }
2223
+ return { success: false, error: 'Web server not available' };
2224
+ }
2225
+
2226
+ getSongRequests() {
2227
+ return this.webServer ? this.webServer.getSongRequests() : [];
2228
+ }
2229
+
2230
+ // Player control methods for web server
2231
+ playerPlay() {
2232
+ console.log('🎮 Admin play command - using playerService');
2233
+ return playerService.play(this);
2234
+ }
2235
+
2236
+ playerPause() {
2237
+ return playerService.pause(this);
2238
+ }
2239
+
2240
+ playerRestart() {
2241
+ return playerService.restart(this);
2242
+ }
2243
+
2244
+ playerSeek(position) {
2245
+ return playerService.seek(this, position);
2246
+ }
2247
+
2248
+ playerNext() {
2249
+ return playerService.playNext(this);
2250
+ }
2251
+
2252
+ clearQueue() {
2253
+ // Use shared queueService
2254
+ const result = queueService.clearQueue(this.appState);
2255
+ // Update legacy queue for compatibility
2256
+ this.songQueue = [];
2257
+ return result;
2258
+ }
2259
+
2260
+ // Removed duplicate getCurrentSong() - using appState version above (line 2021)
2261
+
2262
+ // Position broadcasting timer
2263
+ startPositionBroadcasting() {
2264
+ if (this.positionTimer) {
2265
+ clearInterval(this.positionTimer);
2266
+ }
2267
+
2268
+ this.positionTimer = setInterval(() => {
2269
+ const hasWebServer = Boolean(this.webServer);
2270
+ const hasCurrentSong = Boolean(this.appState.state.currentSong);
2271
+
2272
+ if (hasWebServer && hasCurrentSong) {
2273
+ // Get interpolated position from AppState
2274
+ const currentTime = this.appState.getCurrentPosition();
2275
+ const isPlaying = this.appState.state.playback.isPlaying;
2276
+
2277
+ const songId = this.appState.state.currentSong
2278
+ ? `${this.appState.state.currentSong.title} - ${this.appState.state.currentSong.artist}`
2279
+ : 'Unknown Song';
2280
+
2281
+ this.webServer.broadcastPlaybackPosition(currentTime, isPlaying, songId);
2282
+ }
2283
+ }, 1000); // Every second
2284
+ }
2285
+
2286
+ // Clean up web server on app close
2287
+ async cleanup() {
2288
+ if (this.positionTimer) {
2289
+ clearInterval(this.positionTimer);
2290
+ this.positionTimer = null;
2291
+ }
2292
+
2293
+ if (this.socket) {
2294
+ this.socket.disconnect();
2295
+ this.socket = null;
2296
+ }
2297
+
2298
+ if (this.webServer) {
2299
+ this.webServer.stop();
2300
+ }
2301
+
2302
+ // Save settings immediately before exiting
2303
+ if (this.settings) {
2304
+ await this.settings.saveNow();
2305
+ }
2306
+
2307
+ // Save state before exiting
2308
+ if (this.statePersistence) {
2309
+ await this.statePersistence.cleanup();
2310
+ }
2311
+ }
2312
+ }
2313
+
2314
+ const kaiApp = new KaiPlayerApp();
2315
+
2316
+ // Handle uncaught exceptions and errors without showing alert dialogs
2317
+ process.on('uncaughtException', (error) => {
2318
+ console.error('🚨 Uncaught Exception:', error);
2319
+ });
2320
+
2321
+ process.on('unhandledRejection', (reason, promise) => {
2322
+ console.error('🚨 Unhandled Rejection at:', promise, 'reason:', reason);
2323
+ });
2324
+
2325
+ // Ensure settings are saved before app quits
2326
+ app.on('before-quit', async (event) => {
2327
+ if (!kaiApp.isQuitting) {
2328
+ event.preventDefault();
2329
+ kaiApp.isQuitting = true;
2330
+ await kaiApp.cleanup();
2331
+ app.quit();
2332
+ }
2333
+ });
2334
+
2335
+ app.on('window-all-closed', async () => {
2336
+ // Clean up web server and save state
2337
+ if (!kaiApp.isQuitting) {
2338
+ await kaiApp.cleanup();
2339
+ }
2340
+
2341
+ // Quit the app when all windows are closed, even on macOS
2342
+ app.quit();
2343
+ });
2344
+
2345
+ app.on('activate', () => {
2346
+ if (BrowserWindow.getAllWindows().length === 0) {
2347
+ kaiApp.createMainWindow();
2348
+ }
2349
+ });
2350
+
2351
+ kaiApp.initialize().catch(console.error);