loukai-app 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +558 -0
- package/bin/loukai.js +32 -0
- package/package.json +243 -0
- package/src/main/appState.js +250 -0
- package/src/main/audioEngine.js +478 -0
- package/src/main/creator/conversionService.js +503 -0
- package/src/main/creator/downloadManager.js +1128 -0
- package/src/main/creator/ffmpegService.js +487 -0
- package/src/main/creator/installLogger.js +51 -0
- package/src/main/creator/keyDetection.js +212 -0
- package/src/main/creator/llmService.js +370 -0
- package/src/main/creator/lrclibService.js +340 -0
- package/src/main/creator/python/crepe_runner.py +189 -0
- package/src/main/creator/python/demucs_runner.py +158 -0
- package/src/main/creator/python/whisper_runner.py +172 -0
- package/src/main/creator/pythonRunner.js +268 -0
- package/src/main/creator/stemBuilder.js +491 -0
- package/src/main/creator/systemChecker.js +474 -0
- package/src/main/handlers/appHandlers.js +45 -0
- package/src/main/handlers/audioHandlers.js +33 -0
- package/src/main/handlers/autotuneHandlers.js +28 -0
- package/src/main/handlers/canvasHandlers.js +84 -0
- package/src/main/handlers/creatorHandlers.js +159 -0
- package/src/main/handlers/editorHandlers.js +98 -0
- package/src/main/handlers/effectsHandlers.js +100 -0
- package/src/main/handlers/fileHandlers.js +45 -0
- package/src/main/handlers/index.js +78 -0
- package/src/main/handlers/libraryHandlers.js +96 -0
- package/src/main/handlers/mixerHandlers.js +64 -0
- package/src/main/handlers/playerHandlers.js +39 -0
- package/src/main/handlers/preferencesHandlers.js +46 -0
- package/src/main/handlers/queueHandlers.js +81 -0
- package/src/main/handlers/rendererHandlers.js +63 -0
- package/src/main/handlers/settingsHandlers.js +42 -0
- package/src/main/handlers/webServerHandlers.js +105 -0
- package/src/main/main.js +2351 -0
- package/src/main/preload.js +252 -0
- package/src/main/settingsManager.js +139 -0
- package/src/main/statePersistence.js +193 -0
- package/src/main/utils/pathValidator.js +112 -0
- package/src/main/webServer.js +2535 -0
- package/src/native/autotune.js +417 -0
- package/src/renderer/adapters/ElectronBridge.js +677 -0
- package/src/renderer/canvas.html +80 -0
- package/src/renderer/components/App.jsx +303 -0
- package/src/renderer/components/AppRoot.jsx +37 -0
- package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
- package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
- package/src/renderer/components/MixerTab.jsx +233 -0
- package/src/renderer/components/MixerTabWrapper.jsx +31 -0
- package/src/renderer/components/PortalSelect.jsx +239 -0
- package/src/renderer/components/QueueTab.jsx +116 -0
- package/src/renderer/components/RequestsListWrapper.jsx +78 -0
- package/src/renderer/components/ServerTab.jsx +472 -0
- package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
- package/src/renderer/components/StatusBar.jsx +92 -0
- package/src/renderer/components/TabNavigation.jsx +77 -0
- package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
- package/src/renderer/components/creator/CreateTab.jsx +1236 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
- package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
- package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/dist/renderer.css +1 -0
- package/src/renderer/dist/renderer.js +62 -0
- package/src/renderer/dist/renderer.js.map +1 -0
- package/src/renderer/dist/renderer.woff2 +0 -0
- package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
- package/src/renderer/index.html +24 -0
- package/src/renderer/index.html.backup +372 -0
- package/src/renderer/js/PlayerInterface.js +267 -0
- package/src/renderer/js/autoTuneWorklet.js +224 -0
- package/src/renderer/js/butterchurnVerify.js +46 -0
- package/src/renderer/js/canvas-app.js +114 -0
- package/src/renderer/js/cdgPlayer.js +685 -0
- package/src/renderer/js/kaiPlayer.js +1200 -0
- package/src/renderer/js/karaokeRenderer.js +3392 -0
- package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/js/microphoneEngine.js +656 -0
- package/src/renderer/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/js/player.js +232 -0
- package/src/renderer/js/referencePitchTracker.js +130 -0
- package/src/renderer/js/songLoaders.js +334 -0
- package/src/renderer/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/js/webrtcManager.js +511 -0
- package/src/renderer/lib/butterchurn.min.js +6739 -0
- package/src/renderer/lib/butterchurnPresets.min.js +1 -0
- package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
- package/src/renderer/lib/cdgraphics.js +299 -0
- package/src/renderer/public/js/autoTuneWorklet.js +224 -0
- package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/react-entry.jsx +44 -0
- package/src/renderer/styles/tailwind.css +106 -0
- package/src/renderer/utils/qrCodeGenerator.js +98 -0
- package/src/renderer/vite.config.js +31 -0
- package/src/shared/adapters/BridgeInterface.js +195 -0
- package/src/shared/components/EffectsPanel.jsx +177 -0
- package/src/shared/components/LibraryPanel.jsx +701 -0
- package/src/shared/components/LineDetailCanvas.jsx +167 -0
- package/src/shared/components/LyricLine.jsx +505 -0
- package/src/shared/components/LyricRejection.jsx +84 -0
- package/src/shared/components/LyricSuggestion.jsx +80 -0
- package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
- package/src/shared/components/MixerPanel.jsx +94 -0
- package/src/shared/components/PlayerControls.jsx +206 -0
- package/src/shared/components/PortalSelect.jsx +239 -0
- package/src/shared/components/QueueList.jsx +365 -0
- package/src/shared/components/QuickSearch.jsx +126 -0
- package/src/shared/components/RequestsList.jsx +121 -0
- package/src/shared/components/SongEditor.jsx +1362 -0
- package/src/shared/components/SongInfoBar.jsx +81 -0
- package/src/shared/components/ThemeToggle.jsx +106 -0
- package/src/shared/components/Toast.jsx +30 -0
- package/src/shared/components/VisualizationSettings.jsx +243 -0
- package/src/shared/constants.js +95 -0
- package/src/shared/context/BridgeContext.jsx +32 -0
- package/src/shared/contexts/AudioContext.jsx +37 -0
- package/src/shared/contexts/PlayerContext.jsx +66 -0
- package/src/shared/contexts/SettingsContext.jsx +50 -0
- package/src/shared/defaults.js +158 -0
- package/src/shared/formatUtils.js +59 -0
- package/src/shared/formatUtils.test.js +207 -0
- package/src/shared/hooks/useAppState.js +97 -0
- package/src/shared/hooks/useAudioEngine.js +264 -0
- package/src/shared/hooks/usePlayer.js +89 -0
- package/src/shared/hooks/useSettingsPersistence.js +74 -0
- package/src/shared/hooks/useWebRTC.js +118 -0
- package/src/shared/ipcContracts.js +299 -0
- package/src/shared/package.json +3 -0
- package/src/shared/services/creatorService.js +373 -0
- package/src/shared/services/creatorService.test.js +413 -0
- package/src/shared/services/editorService.js +213 -0
- package/src/shared/services/editorService.test.js +219 -0
- package/src/shared/services/effectsService.js +271 -0
- package/src/shared/services/effectsService.test.js +418 -0
- package/src/shared/services/libraryService.js +438 -0
- package/src/shared/services/libraryService.test.js +474 -0
- package/src/shared/services/mixerService.js +172 -0
- package/src/shared/services/mixerService.test.js +399 -0
- package/src/shared/services/playerService.js +221 -0
- package/src/shared/services/playerService.test.js +357 -0
- package/src/shared/services/preferencesService.js +219 -0
- package/src/shared/services/queueService.js +226 -0
- package/src/shared/services/queueService.test.js +430 -0
- package/src/shared/services/requestsService.js +155 -0
- package/src/shared/services/requestsService.test.js +362 -0
- package/src/shared/services/serverSettingsService.js +151 -0
- package/src/shared/services/settingsService.js +257 -0
- package/src/shared/services/settingsService.test.js +295 -0
- package/src/shared/state/StateManager.js +263 -0
- package/src/shared/utils/audio.js +42 -0
- package/src/shared/utils/format.js +32 -0
- package/src/shared/utils/lyricsUtils.js +162 -0
- package/src/test/setup.js +40 -0
- package/src/utils/cdgLoader.js +180 -0
- package/src/utils/m4aLoader.js +333 -0
- package/src/web/App.jsx +578 -0
- package/src/web/adapters/WebBridge.js +428 -0
- package/src/web/components/PlayerSettingsPanel.jsx +231 -0
- package/src/web/components/SongSearch.jsx +180 -0
- package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
- package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
- package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
- package/src/web/dist/index.html +15 -0
- package/src/web/index.html +14 -0
- package/src/web/main.jsx +10 -0
- package/src/web/package-lock.json +1765 -0
- package/src/web/pages/SongRequestPage.jsx +619 -0
- package/src/web/styles/tailwind.css +68 -0
- package/src/web/vite.config.js +27 -0
- package/static/fonts/material-icons.woff2 +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
- package/static/images/butterchurn-screenshots/An AdamFX n Martin Infusion 2 flexi - Why The Sky Looks Diffrent Today - AdamFx n Martin Infusion - Tack Tile Disfunction B.png +0 -0
- package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
- proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
- package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
- package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
- package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
- package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
- package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
- package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
- package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
- package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
- package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
- package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
- package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
- package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
- package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
- package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
- package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
- package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
- package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
- package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
- package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
- package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
- package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
- package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
- package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
- package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
- package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
- package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
- package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
- package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - Rovastar - Sunflower Passion _Enlightment Mix__Phat_edit _ flexi und martin shaders - circumflex in character classes in regular expression.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
- package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
- package/static/images/logo-512.png +0 -0
- package/static/images/logo.png +0 -0
- package/static/loukai-logo.png +0 -0
- package/static/screenshot-generator.html +610 -0
package/src/main/main.js
ADDED
|
@@ -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);
|