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,701 @@
1
+ /**
2
+ * LibraryPanel - Full library browser with alphabet filtering and pagination
3
+ *
4
+ * Features:
5
+ * - Alphabet navigation (A-Z, #)
6
+ * - Pagination (100 songs per page)
7
+ * - Search functionality
8
+ * - Table view with sorting
9
+ * - Add to queue / Load song actions
10
+ */
11
+
12
+ import { useState, useEffect, useCallback } from 'react';
13
+ import { getFormatIcon, formatDuration } from '../formatUtils.js';
14
+
15
+ function SongInfoModal({ song, onClose }) {
16
+ if (!song) return null;
17
+
18
+ console.log('🎵 SongInfoModal rendering for:', song.title);
19
+
20
+ return (
21
+ <div
22
+ className="fixed inset-0 bg-black/70 flex items-center justify-center z-[10000]"
23
+ onClick={onClose}
24
+ >
25
+ <div
26
+ className="bg-gray-800 dark:bg-gray-900 border border-gray-600 dark:border-gray-700 rounded-lg w-[90%] max-w-[500px] max-h-[80vh] overflow-hidden flex flex-col"
27
+ onClick={(e) => e.stopPropagation()}
28
+ >
29
+ <div className="flex justify-between items-center px-5 py-4 border-b border-gray-700 dark:border-gray-800">
30
+ <h2 className="m-0 text-lg font-semibold text-white">Song Information</h2>
31
+ <button
32
+ className="bg-none border-none text-white text-[32px] leading-none cursor-pointer p-0 w-8 h-8 flex items-center justify-center rounded transition-colors hover:bg-gray-700 dark:hover:bg-gray-800"
33
+ onClick={onClose}
34
+ >
35
+ &times;
36
+ </button>
37
+ </div>
38
+ <div className="p-5 overflow-y-auto">
39
+ <div className="space-y-0">
40
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
41
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Title:</span>
42
+ <span className="text-white">{song.title}</span>
43
+ </div>
44
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
45
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Artist:</span>
46
+ <span className="text-white">{song.artist}</span>
47
+ </div>
48
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
49
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Album:</span>
50
+ <span className="text-white">{song.album || 'N/A'}</span>
51
+ </div>
52
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
53
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Genre:</span>
54
+ <span className="text-white">{song.genre || 'N/A'}</span>
55
+ </div>
56
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
57
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Key:</span>
58
+ <span className="text-white">{song.key || 'N/A'}</span>
59
+ </div>
60
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
61
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Year:</span>
62
+ <span className="text-white">{song.year || 'N/A'}</span>
63
+ </div>
64
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
65
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Duration:</span>
66
+ <span className="text-white">{formatDuration(song.duration)}</span>
67
+ </div>
68
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
69
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Format:</span>
70
+ <span className="text-white">{song.format || 'N/A'}</span>
71
+ </div>
72
+ {song.tags && song.tags.length > 0 && (
73
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5 border-b border-gray-700/50 dark:border-gray-800/50">
74
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Tags:</span>
75
+ <span className="text-white flex flex-wrap gap-1.5">
76
+ {song.tags.map((tag) => (
77
+ <span
78
+ key={tag}
79
+ className={`px-2 py-0.5 rounded-full text-xs font-medium ${
80
+ tag === 'edited'
81
+ ? 'bg-blue-600/30 text-blue-300'
82
+ : tag === 'ai_corrected'
83
+ ? 'bg-purple-600/30 text-purple-300'
84
+ : 'bg-gray-600/30 text-gray-300'
85
+ }`}
86
+ >
87
+ {tag}
88
+ </span>
89
+ ))}
90
+ </span>
91
+ </div>
92
+ )}
93
+ <div className="grid grid-cols-[120px_1fr] gap-3 py-2.5">
94
+ <span className="font-semibold text-gray-300 dark:text-gray-400">Path:</span>
95
+ <span className="text-white break-all text-[11px]">{song.path}</span>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ export function LibraryPanel({ bridge, showSetFolder = false, showFullRefresh = false }) {
105
+ const [songs, setSongs] = useState([]);
106
+ const [filteredSongs, setFilteredSongs] = useState([]);
107
+ const [currentLetter, setCurrentLetter] = useState(null);
108
+ const [availableLetters, setAvailableLetters] = useState([]);
109
+ const [currentPage, setCurrentPage] = useState(1);
110
+ const [searchTerm, setSearchTerm] = useState('');
111
+ const [songsFolder, setSongsFolder] = useState(null);
112
+ const [loading, setLoading] = useState(false);
113
+ const [modalSong, setModalSong] = useState(null);
114
+ const [scanProgress, setScanProgress] = useState(null); // { current, total }
115
+
116
+ const pageSize = 100;
117
+
118
+ // Wrap functions in useCallback to stabilize references for useEffect dependencies
119
+ const loadLetterPage = useCallback((letter, page, songsList) => {
120
+ setCurrentLetter(letter);
121
+ setCurrentPage(page);
122
+ setSearchTerm('');
123
+
124
+ // Filter songs by first letter of artist
125
+ const letterSongs = songsList.filter((song) => {
126
+ const artist = song.artist || song.title || song.name;
127
+ if (!artist) return false;
128
+
129
+ const firstChar = artist.trim()[0].toUpperCase();
130
+ if (letter === '#') {
131
+ return !/[A-Z]/.test(firstChar);
132
+ }
133
+ return firstChar === letter;
134
+ });
135
+
136
+ // Sort and paginate
137
+ const sortedSongs = letterSongs.sort((a, b) => {
138
+ const artistA = (a.artist || a.title || '').toLowerCase();
139
+ const artistB = (b.artist || b.title || '').toLowerCase();
140
+ return artistA.localeCompare(artistB);
141
+ });
142
+
143
+ setFilteredSongs(sortedSongs);
144
+ }, []);
145
+
146
+ const calculateAvailableLetters = useCallback(
147
+ (songsList, shouldAutoSelect = true) => {
148
+ const letterSet = new Set();
149
+
150
+ songsList.forEach((song) => {
151
+ const artist = song.artist || song.title || song.name;
152
+ if (artist) {
153
+ const firstChar = artist.trim()[0].toUpperCase();
154
+ if (/[A-Z]/.test(firstChar)) {
155
+ letterSet.add(firstChar);
156
+ } else {
157
+ letterSet.add('#');
158
+ }
159
+ }
160
+ });
161
+
162
+ let letters = Array.from(letterSet).sort();
163
+ // Put '#' at the end
164
+ if (letters.includes('#')) {
165
+ letters = letters.filter((l) => l !== '#');
166
+ letters.push('#');
167
+ }
168
+
169
+ setAvailableLetters(letters);
170
+
171
+ // Auto-select first letter if requested and songs exist
172
+ if (shouldAutoSelect && letters.length > 0) {
173
+ const firstLetter = letters.includes('A') ? 'A' : letters[0];
174
+ loadLetterPage(firstLetter, 1, songsList);
175
+ }
176
+ },
177
+ [loadLetterPage]
178
+ );
179
+
180
+ const loadLibrary = useCallback(async () => {
181
+ try {
182
+ setLoading(true);
183
+ const folder = await bridge.getSongsFolder();
184
+ console.log('📁 Songs folder:', folder);
185
+ setSongsFolder(folder);
186
+
187
+ if (folder) {
188
+ const result = await bridge.getCachedLibrary();
189
+ console.log('📚 Cached library result:', result);
190
+ const librarySongs = result.files || [];
191
+ console.log('🎵 Library songs count:', librarySongs.length);
192
+ setSongs(librarySongs);
193
+ calculateAvailableLetters(librarySongs); // This will auto-select first letter
194
+ } else {
195
+ console.log('❌ No songs folder set');
196
+ }
197
+ } catch (error) {
198
+ console.error('Failed to load library:', error);
199
+ } finally {
200
+ setLoading(false);
201
+ }
202
+ }, [bridge, calculateAvailableLetters]);
203
+
204
+ // Listen for scan progress events (Electron renderer only)
205
+ useEffect(() => {
206
+ if (typeof window !== 'undefined' && window.kaiAPI?.events) {
207
+ const handleScanProgress = (event, data) => {
208
+ setScanProgress({ current: data.current, total: data.total });
209
+ };
210
+
211
+ const handleScanComplete = (event, data) => {
212
+ console.log(`📚 Background scan complete: ${data.count} songs`);
213
+ setScanProgress(null);
214
+ loadLibrary(); // Reload library with new scanned songs
215
+ };
216
+
217
+ const handleFolderSet = (event, folder) => {
218
+ console.log(`📁 Songs folder updated: ${folder}`);
219
+ setSongsFolder(folder);
220
+ loadLibrary(); // Reload library with new folder
221
+ };
222
+
223
+ const handleSongUpdated = (event, data) => {
224
+ console.log(`🎵 Song updated: ${data.path}`);
225
+ // Update the song in the songs list
226
+ setSongs((prevSongs) => {
227
+ const songIndex = prevSongs.findIndex((s) => s.path === data.path);
228
+ if (songIndex !== -1) {
229
+ const updatedSongs = [...prevSongs];
230
+ updatedSongs[songIndex] = { ...updatedSongs[songIndex], ...data.metadata };
231
+ return updatedSongs;
232
+ }
233
+ return prevSongs;
234
+ });
235
+
236
+ // Update filtered songs if they're currently displayed
237
+ setFilteredSongs((prevFiltered) => {
238
+ const songIndex = prevFiltered.findIndex((s) => s.path === data.path);
239
+ if (songIndex !== -1) {
240
+ const updatedFiltered = [...prevFiltered];
241
+ updatedFiltered[songIndex] = { ...updatedFiltered[songIndex], ...data.metadata };
242
+ return updatedFiltered;
243
+ }
244
+ return prevFiltered;
245
+ });
246
+ };
247
+
248
+ window.kaiAPI.events.on('library:scanProgress', handleScanProgress);
249
+ window.kaiAPI.events.on('library:scanComplete', handleScanComplete);
250
+ window.kaiAPI.events.on('library:folderSet', handleFolderSet);
251
+ window.kaiAPI.events.on('library:songUpdated', handleSongUpdated);
252
+
253
+ return () => {
254
+ window.kaiAPI.events.removeListener('library:scanProgress', handleScanProgress);
255
+ window.kaiAPI.events.removeListener('library:scanComplete', handleScanComplete);
256
+ window.kaiAPI.events.removeListener('library:folderSet', handleFolderSet);
257
+ window.kaiAPI.events.removeListener('library:songUpdated', handleSongUpdated);
258
+ };
259
+ }
260
+ }, [loadLibrary]);
261
+
262
+ // Listen for library updates from socket (Web admin only)
263
+ useEffect(() => {
264
+ if (bridge?.socket) {
265
+ const handleLibraryRefreshed = (data) => {
266
+ console.log(`📚 Library refreshed from remote: ${data.count} songs`);
267
+ loadLibrary(); // Reload library
268
+ };
269
+
270
+ const handleSongUpdated = (data) => {
271
+ console.log(`🎵 Song updated: ${data.path}`);
272
+ // Update the song in the songs list
273
+ setSongs((prevSongs) => {
274
+ const songIndex = prevSongs.findIndex((s) => s.path === data.path);
275
+ if (songIndex !== -1) {
276
+ const updatedSongs = [...prevSongs];
277
+ updatedSongs[songIndex] = { ...updatedSongs[songIndex], ...data.metadata };
278
+ return updatedSongs;
279
+ }
280
+ return prevSongs;
281
+ });
282
+
283
+ // Update filtered songs if they're currently displayed
284
+ setFilteredSongs((prevFiltered) => {
285
+ const songIndex = prevFiltered.findIndex((s) => s.path === data.path);
286
+ if (songIndex !== -1) {
287
+ const updatedFiltered = [...prevFiltered];
288
+ updatedFiltered[songIndex] = { ...updatedFiltered[songIndex], ...data.metadata };
289
+ return updatedFiltered;
290
+ }
291
+ return prevFiltered;
292
+ });
293
+ };
294
+
295
+ bridge.socket.on('library-refreshed', handleLibraryRefreshed);
296
+ bridge.socket.on('library:songUpdated', handleSongUpdated);
297
+
298
+ return () => {
299
+ bridge.socket.off('library-refreshed', handleLibraryRefreshed);
300
+ bridge.socket.off('library:songUpdated', handleSongUpdated);
301
+ };
302
+ }
303
+ }, [bridge, loadLibrary]);
304
+
305
+ // Load library on mount
306
+ useEffect(() => {
307
+ loadLibrary();
308
+ }, [loadLibrary]);
309
+
310
+ const handleSearch = (term) => {
311
+ setSearchTerm(term);
312
+ setCurrentLetter(null);
313
+ setCurrentPage(1);
314
+
315
+ if (!term.trim()) {
316
+ setFilteredSongs([]);
317
+ return;
318
+ }
319
+
320
+ const searchLower = term.toLowerCase();
321
+ const results = songs.filter((song) => {
322
+ return (
323
+ (song.title || '').toLowerCase().includes(searchLower) ||
324
+ (song.artist || '').toLowerCase().includes(searchLower) ||
325
+ (song.album || '').toLowerCase().includes(searchLower)
326
+ );
327
+ });
328
+
329
+ setFilteredSongs(results);
330
+ };
331
+
332
+ const handleSetFolder = async () => {
333
+ try {
334
+ const folder = await bridge.setSongsFolder();
335
+ if (folder) {
336
+ setSongsFolder(folder);
337
+ await loadLibrary();
338
+ }
339
+ } catch (error) {
340
+ console.error('Failed to set folder:', error);
341
+ }
342
+ };
343
+
344
+ const handleSync = async () => {
345
+ if (!songsFolder) return;
346
+
347
+ try {
348
+ setLoading(true);
349
+ await bridge.syncLibrary();
350
+ await loadLibrary();
351
+ } catch (error) {
352
+ console.error('Failed to sync:', error);
353
+ } finally {
354
+ setLoading(false);
355
+ setScanProgress(null); // Clear progress when done
356
+ }
357
+ };
358
+
359
+ const handleRefresh = async () => {
360
+ if (!songsFolder) return;
361
+
362
+ try {
363
+ setLoading(true);
364
+ await bridge.scanLibrary();
365
+ await loadLibrary();
366
+ } catch (error) {
367
+ console.error('Failed to refresh:', error);
368
+ } finally {
369
+ setLoading(false);
370
+ setScanProgress(null); // Clear progress when done
371
+ }
372
+ };
373
+
374
+ const handleAddToQueue = async (song) => {
375
+ try {
376
+ await bridge.addToQueue({
377
+ path: song.path,
378
+ title: song.title,
379
+ artist: song.artist,
380
+ duration: song.duration,
381
+ });
382
+ } catch (error) {
383
+ console.error('Failed to add to queue:', error);
384
+ }
385
+ };
386
+
387
+ const handleShowInfo = (song) => {
388
+ console.log('📋 Opening song info modal for:', song.title);
389
+ setModalSong(song);
390
+ };
391
+
392
+ // Pagination
393
+ const totalPages = Math.ceil(filteredSongs.length / pageSize);
394
+ const startIndex = (currentPage - 1) * pageSize;
395
+ const endIndex = startIndex + pageSize;
396
+ const currentPageSongs = filteredSongs.slice(startIndex, endIndex);
397
+
398
+ // Smart pagination - show limited page numbers around current page
399
+ const getPageNumbers = () => {
400
+ const maxButtons = 7; // Show max 7 page buttons
401
+ if (totalPages <= maxButtons) {
402
+ // Show all pages if total is small
403
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
404
+ }
405
+
406
+ const pages = [];
407
+ const halfRange = Math.floor((maxButtons - 3) / 2); // Reserve 3 for first, last, and ellipsis
408
+
409
+ // Always show first page
410
+ pages.push(1);
411
+
412
+ let startPage = Math.max(2, currentPage - halfRange);
413
+ let endPage = Math.min(totalPages - 1, currentPage + halfRange);
414
+
415
+ // Adjust if we're near the beginning
416
+ if (currentPage <= halfRange + 2) {
417
+ endPage = Math.min(maxButtons - 1, totalPages - 1);
418
+ }
419
+
420
+ // Adjust if we're near the end
421
+ if (currentPage >= totalPages - halfRange - 1) {
422
+ startPage = Math.max(2, totalPages - maxButtons + 2);
423
+ }
424
+
425
+ // Add ellipsis if needed before
426
+ if (startPage > 2) {
427
+ pages.push('...');
428
+ }
429
+
430
+ // Add middle pages
431
+ for (let i = startPage; i <= endPage; i++) {
432
+ pages.push(i);
433
+ }
434
+
435
+ // Add ellipsis if needed after
436
+ if (endPage < totalPages - 1) {
437
+ pages.push('...');
438
+ }
439
+
440
+ // Always show last page
441
+ if (totalPages > 1) {
442
+ pages.push(totalPages);
443
+ }
444
+
445
+ return pages;
446
+ };
447
+
448
+ const allLetters = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), '#'];
449
+
450
+ return (
451
+ <div className="flex flex-col h-full gap-1 overflow-hidden">
452
+ {/* Header Controls */}
453
+ <div className="flex flex-col gap-1.5 pb-1 border-b border-gray-200 dark:border-gray-700 shrink-0">
454
+ <div className="flex gap-2 flex-wrap items-center">
455
+ {showSetFolder && (
456
+ <button
457
+ onClick={handleSetFolder}
458
+ className="flex items-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-white cursor-pointer text-sm hover:bg-gray-200 dark:hover:bg-gray-750 disabled:opacity-50 disabled:cursor-not-allowed"
459
+ >
460
+ <span className="material-icons text-lg">folder_open</span>
461
+ Set Songs Folder
462
+ </button>
463
+ )}
464
+ <button
465
+ onClick={handleSync}
466
+ className="flex items-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-white cursor-pointer text-sm hover:bg-gray-200 dark:hover:bg-gray-750 disabled:opacity-50 disabled:cursor-not-allowed"
467
+ disabled={!songsFolder || loading}
468
+ >
469
+ <span className="material-icons text-lg">sync</span>
470
+ Sync
471
+ </button>
472
+ {showFullRefresh && (
473
+ <button
474
+ onClick={handleRefresh}
475
+ className="flex items-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-white cursor-pointer text-sm hover:bg-gray-200 dark:hover:bg-gray-750 disabled:opacity-50 disabled:cursor-not-allowed"
476
+ disabled={!songsFolder || loading}
477
+ >
478
+ <span className="material-icons text-lg">refresh</span>
479
+ Full Refresh
480
+ </button>
481
+ )}
482
+ <div className="flex-1 min-w-[200px]">
483
+ <input
484
+ type="text"
485
+ className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-white text-sm focus:outline-none focus:border-blue-500"
486
+ placeholder="Search songs..."
487
+ value={searchTerm}
488
+ onChange={(e) => handleSearch(e.target.value)}
489
+ />
490
+ </div>
491
+ </div>
492
+ <div className="flex gap-4 text-xs text-gray-500 dark:text-gray-400">
493
+ <span>{songs.length} songs</span>
494
+ {songsFolder && <span>{songsFolder}</span>}
495
+ </div>
496
+ {scanProgress && (
497
+ <div className="flex flex-col gap-1 py-2">
498
+ <div className="w-full h-5 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
499
+ <div
500
+ className="h-full bg-blue-600 transition-all duration-300"
501
+ style={{
502
+ width: `${scanProgress.total > 0 ? Math.round((scanProgress.current / scanProgress.total) * 100) : 0}%`,
503
+ }}
504
+ />
505
+ </div>
506
+ <div className="text-xs text-gray-700 dark:text-gray-300 text-center">
507
+ {scanProgress.message ? (
508
+ scanProgress.message
509
+ ) : (
510
+ <>
511
+ Scanning: {scanProgress.current} / {scanProgress.total} files (
512
+ {scanProgress.total > 0
513
+ ? Math.round((scanProgress.current / scanProgress.total) * 100)
514
+ : 0}
515
+ %)
516
+ </>
517
+ )}
518
+ </div>
519
+ </div>
520
+ )}
521
+ </div>
522
+
523
+ {/* Alphabet Navigation */}
524
+ {!searchTerm && !scanProgress && (
525
+ <div className="flex flex-col gap-1 py-1 px-2 bg-gray-100 dark:bg-gray-800/50 rounded-md shrink-0">
526
+ <div className="text-xs font-semibold text-gray-600 dark:text-gray-300">
527
+ Browse by Artist:
528
+ </div>
529
+ <div className="flex flex-wrap gap-1">
530
+ {allLetters.map((letter) => {
531
+ const isAvailable = availableLetters.includes(letter);
532
+ const isActive = currentLetter === letter;
533
+
534
+ return (
535
+ <button
536
+ key={letter}
537
+ className={`w-8 h-8 p-0 rounded text-sm font-semibold cursor-pointer transition-all ${isActive ? 'bg-blue-600 border-blue-600 text-white' : isAvailable ? 'bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-750 hover:scale-105' : 'bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-600 opacity-30 cursor-not-allowed'}`}
538
+ onClick={() => isAvailable && loadLetterPage(letter, 1, songs)}
539
+ disabled={!isAvailable}
540
+ >
541
+ {letter}
542
+ </button>
543
+ );
544
+ })}
545
+ </div>
546
+ </div>
547
+ )}
548
+
549
+ {/* Library Table */}
550
+ {loading ? (
551
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 p-16 text-center text-base text-gray-700 dark:text-gray-300">
552
+ Loading library...
553
+ </div>
554
+ ) : filteredSongs.length > 0 ? (
555
+ <>
556
+ <div className="flex-1 min-h-0 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md">
557
+ <table className="w-full border-collapse">
558
+ <thead className="sticky top-0 bg-gray-100 dark:bg-gray-800 z-10">
559
+ <tr>
560
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
561
+ Title
562
+ </th>
563
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
564
+ Artist
565
+ </th>
566
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
567
+ Album
568
+ </th>
569
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
570
+ Genre
571
+ </th>
572
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
573
+ Key
574
+ </th>
575
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
576
+ Duration
577
+ </th>
578
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
579
+ Year
580
+ </th>
581
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border-b-2 border-gray-200 dark:border-gray-700">
582
+ Actions
583
+ </th>
584
+ </tr>
585
+ </thead>
586
+ <tbody className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
587
+ {currentPageSongs.map((song, index) => (
588
+ <tr key={index} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
589
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
590
+ <span className="mr-1.5 text-base">{getFormatIcon(song.format)}</span>
591
+ {song.title}
592
+ </td>
593
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
594
+ {song.artist}
595
+ </td>
596
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
597
+ {song.album || '-'}
598
+ </td>
599
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
600
+ {song.genre || '-'}
601
+ </td>
602
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
603
+ {song.key || '-'}
604
+ </td>
605
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
606
+ {formatDuration(song.duration)}
607
+ </td>
608
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
609
+ {song.year || '-'}
610
+ </td>
611
+ <td className="px-3 py-1.5 text-xs leading-relaxed border-b border-gray-200 dark:border-gray-800/50">
612
+ <div className="flex flex-row gap-1 items-center">
613
+ <button
614
+ className="w-7 h-7 min-w-[28px] min-h-[28px] max-w-[28px] max-h-[28px] p-0 flex items-center justify-center bg-transparent border border-gray-200 dark:border-gray-700 rounded text-gray-700 dark:text-white cursor-pointer transition-all flex-shrink-0 hover:bg-gray-100 dark:hover:bg-gray-800 hover:border-blue-600"
615
+ onClick={() => handleAddToQueue(song)}
616
+ title="Add to Queue"
617
+ >
618
+ <span className="material-icons text-base leading-none">
619
+ playlist_add
620
+ </span>
621
+ </button>
622
+ <button
623
+ className="w-7 h-7 min-w-[28px] min-h-[28px] max-w-[28px] max-h-[28px] p-0 flex items-center justify-center bg-transparent border border-gray-200 dark:border-gray-700 rounded text-gray-700 dark:text-white cursor-pointer transition-all flex-shrink-0 hover:bg-gray-100 dark:hover:bg-gray-800 hover:border-blue-600"
624
+ onClick={() => handleShowInfo(song)}
625
+ title="Song Info"
626
+ >
627
+ <span className="material-icons text-base leading-none">info</span>
628
+ </button>
629
+ </div>
630
+ </td>
631
+ </tr>
632
+ ))}
633
+ </tbody>
634
+ </table>
635
+ </div>
636
+
637
+ {/* Pagination */}
638
+ {totalPages > 1 && !scanProgress && (
639
+ <div className="flex items-center justify-center gap-3 py-1.5 px-2 bg-gray-100 dark:bg-gray-800/50 rounded-md text-sm shrink-0">
640
+ <button
641
+ className="px-4 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-white cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-750 disabled:opacity-40 disabled:cursor-not-allowed"
642
+ onClick={() => setCurrentPage(currentPage - 1)}
643
+ disabled={currentPage === 1}
644
+ >
645
+ Previous
646
+ </button>
647
+ <div className="flex gap-1 items-center">
648
+ {getPageNumbers().map((page, index) => {
649
+ if (page === '...') {
650
+ return (
651
+ <span
652
+ key={`ellipsis-${index}`}
653
+ className="px-2 py-1.5 text-gray-700 dark:text-gray-300 select-none"
654
+ >
655
+ ...
656
+ </span>
657
+ );
658
+ }
659
+ return (
660
+ <button
661
+ key={page}
662
+ className={`min-w-[36px] px-2 py-1.5 rounded cursor-pointer ${page === currentPage ? 'bg-blue-600 border-blue-600 font-semibold text-white' : 'bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-750'}`}
663
+ onClick={() => setCurrentPage(page)}
664
+ >
665
+ {page}
666
+ </button>
667
+ );
668
+ })}
669
+ </div>
670
+ <span className="text-xs text-gray-500 dark:text-gray-400">
671
+ ({filteredSongs.length} songs)
672
+ </span>
673
+ <button
674
+ className="px-4 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-white cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-750 disabled:opacity-40 disabled:cursor-not-allowed"
675
+ onClick={() => setCurrentPage(currentPage + 1)}
676
+ disabled={currentPage === totalPages}
677
+ >
678
+ Next
679
+ </button>
680
+ </div>
681
+ )}
682
+ </>
683
+ ) : (
684
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 p-16 text-center">
685
+ <div className="text-5xl opacity-50">🎵</div>
686
+ <div className="text-lg font-semibold text-gray-900 dark:text-white">
687
+ {songsFolder ? 'No songs found' : 'No songs library set'}
688
+ </div>
689
+ <div className="text-sm text-gray-500 dark:text-gray-400">
690
+ {songsFolder
691
+ ? 'Try syncing or refreshing your library'
692
+ : 'Click "Set Songs Folder" to choose your music library'}
693
+ </div>
694
+ </div>
695
+ )}
696
+
697
+ {/* Song Info Modal */}
698
+ {modalSong && <SongInfoModal song={modalSong} onClose={() => setModalSong(null)} />}
699
+ </div>
700
+ );
701
+ }