Files
KGStudio/src/App.tsx
T

296 lines
10 KiB
TypeScript

import { useEffect } from 'react';
import './App.css';
import './styles/shared.css';
import { useProjectStore } from './stores/projectStore';
import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler';
import Toolbar from './components/Toolbar';
import StatusBar from './components/StatusBar';
import MainContent from './components/MainContent';
import InstrumentSelection from './components/InstrumentSelection';
import ChatBox from './components/ChatBox';
import { SettingsPanel } from './components/settings';
import LoadingOverlay from './components/common/LoadingOverlay';
import KGOnePanel from './components/KGOnePanel';
import { useEffect as useEffectReact, useState, useRef } from 'react';
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer';
import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
import { KGCore } from './core/KGCore';
import { ConfigManager } from './core/config/ConfigManager';
import { validateFunctionalChordsJSON } from './util/scaleUtil';
import { KGProjectStorage } from './core/io/KGProjectStorage';
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
function App() {
// Enable global keyboard handler for copy/paste and undo/redo
useGlobalKeyboardHandler();
// Use project store instead of local state for project name and tracks
const {
refreshStatus,
loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig,
showInstrumentSelection, showKGOnePanel
} = useProjectStore();
// Track if app has been initialized to prevent multiple initializations
const hasInitialized = useRef(false);
// Load project when component mounts
useEffect(() => {
// Guard against multiple initializations (can happen due to React Strict Mode or rerenders)
if (hasInitialized.current) {
return;
}
hasInitialized.current = true;
const initializeApp = async () => {
// Wipe the reserved "Untitled Project" OPFS folder on every startup so it stays ephemeral
try {
const storage = KGProjectStorage.getInstance();
if (await storage.exists(RESERVED_PROJECT_NAME)) {
await storage.delete(RESERVED_PROJECT_NAME);
}
} catch (error) {
console.warn('Could not clear Untitled Project folder on startup:', error);
}
// Load the current project from KGCore
loadProject(null);
// Initialize ConfigManager first to load config.json and user settings
await ConfigManager.instance().initialize();
// Check for kgone-server.json (managed deployment override)
try {
const kgoneServerResponse = await fetch(`${import.meta.env.BASE_URL}kgone-server.json?ts=${Date.now()}`);
const contentType = kgoneServerResponse.headers.get('Content-Type') ?? '';
if (kgoneServerResponse.ok && contentType.includes('application/json')) {
const data = await kgoneServerResponse.json();
if (data.base_url) {
ConfigManager.instance().setKGOneManagedByServer(data.base_url);
console.log('K.G.One: server-managed config loaded from kgone-server.json, base URL:', data.base_url);
}
if (data.soundfont) {
ConfigManager.instance().setSoundfontManagedByServer(data.soundfont);
console.log('Soundfont: server-managed config loaded from kgone-server.json, base URL:', data.soundfont);
}
}
} catch {
// File not present — user configures manually
}
// Initialize store from config after ConfigManager is ready
await initializeFromConfig();
// Load all mode and chord data files in parallel
try {
const [functionalChordsResponse] = await Promise.all([
fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`)
]);
const [functionalChordsData] = await Promise.all([
functionalChordsResponse.json()
]);
// Store original functional chords data
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData;
console.log(`Loaded original functional chords for ${Object.keys(functionalChordsData).length} modes`);
// Check if custom chord definition exists and is valid
const configManager = ConfigManager.instance();
const customDefinition = configManager.get('chord_guide.chord_definition') as string;
if (customDefinition && customDefinition.trim()) {
const validationResult = validateFunctionalChordsJSON(customDefinition);
if (validationResult.valid) {
KGCore.FUNCTIONAL_CHORDS_DATA = JSON.parse(customDefinition);
console.log('Using custom chord definition from settings');
} else {
KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData;
console.log('Custom chord definition invalid, using original');
}
} else {
KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData;
console.log('No custom chord definition, using original');
}
} catch (error) {
console.error('Failed to load mode/chord data:', error);
// Fallback to defaults
const fallbackData = {
ionian: {
name: 'Ionian',
steps: [2, 2, 1, 2, 2, 2, 1],
T: [],
S: [],
D: [],
chords: {}
}
};
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = fallbackData;
KGCore.FUNCTIONAL_CHORDS_DATA = fallbackData;
}
// Log maxBars after initialization completes
const currentMaxBars = useProjectStore.getState().maxBars;
console.log(`Project max bars: ${currentMaxBars}`);
};
initializeApp();
}, [loadProject, initializeFromConfig]);
// Refresh status periodically to ensure UI is in sync with KGCore
useEffect(() => {
// Initial refresh
refreshStatus();
// Set up interval to refresh status every second
const intervalId = setInterval(() => {
refreshStatus();
}, 1000);
// Clean up interval on unmount
return () => clearInterval(intervalId);
}, [refreshStatus]);
return (
<div className="daw-container">
{/* Top Toolbar */}
<Toolbar />
{/* Main Display Area containing MainContent, ChatBox, and Settings */}
<div className="main-display-area">
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
{!showSettings && (
<>
{showInstrumentSelection && <InstrumentSelection />}
<MainContent />
</>
)}
<KGOnePanel isVisible={showKGOnePanel && !showSettings} />
<ChatBox isVisible={showChatBox && !showSettings} />
</div>
{/* Status Bar */}
<StatusBar />
{/* Global Loading Overlay for instrument buffer loading */}
<GlobalLoadingOverlayContainer />
{/* Migration Loading Overlay */}
<MigrationOverlayContainer />
{/* Bounce/Render Overlay */}
<BounceOverlayContainer />
</div>
);
}
export default App;
// Local component to subscribe to pool events and manage a counter
const GlobalLoadingOverlayContainer: React.FC = () => {
const [loadingCount, setLoadingCount] = useState<number>(() => KGToneBuffersPool.instance().getActiveLoadCount());
const [overdue, setOverdue] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffectReact(() => {
const pool = KGToneBuffersPool.instance();
const listener = (evt: { type: 'start' | 'end'; instrument: string }) => {
setLoadingCount(prev => {
if (evt.type === 'start') return prev + 1;
return Math.max(0, prev - 1);
});
};
pool.addLoadingListener(listener);
return () => {
pool.removeLoadingListener(listener);
};
}, []);
// Handle long-running overlay: after 10s, hide and alert the user, but allow future loads to show overlay again
useEffectReact(() => {
// When loading starts, start a 30s timer if not already overdue/timed
if (loadingCount > 0 && !overdue && timeoutRef.current === null) {
timeoutRef.current = window.setTimeout(() => {
// Only trigger if still loading
if (loadingCount > 0) {
setOverdue(true);
// Friendly alert to the user
window.alert(
'Loading resources is taking longer than expected and may have partially failed. If you notice any playback issues, please refresh the page to retry downloading the audio files.'
);
}
// Clear the timeout handle
timeoutRef.current = null;
}, 10000);
}
// When loading finishes, clear timer and reset overdue state
if (loadingCount === 0) {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (overdue) setOverdue(false);
}
// Cleanup on unmount: clear any pending timer
return () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [loadingCount, overdue]);
return (
<LoadingOverlay
visible={loadingCount > 0 && !overdue}
message={loadingCount > 1 ? `Loading ... (${loadingCount})` : 'Loading ...'}
/>
);
};
// Migration overlay — shown during one-time IndexedDB -> OPFS migration
const MigrationOverlayContainer: React.FC = () => {
const [isMigrating, setIsMigrating] = useState<boolean>(() => KGCore.instance().getIsMigrating());
useEffectReact(() => {
KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
return () => {
KGCore.instance().setMigrationStateChangeCallback(() => {});
};
}, []);
return (
<LoadingOverlay
visible={isMigrating}
message="Migrating projects to new storage..."
/>
);
};
// Bounce/render overlay — shown during offline WAV/MP3 rendering
const BounceOverlayContainer: React.FC = () => {
const [renderMessage, setRenderMessage] = useState<string | null>(null);
useEffectReact(() => {
const renderer = KGOfflineRenderer.instance();
const listener = (evt: RenderingEvent) => {
setRenderMessage(evt.type === 'start' ? (evt.message ?? 'Rendering...') : null);
};
renderer.addRenderingListener(listener);
return () => {
renderer.removeRenderingListener(listener);
};
}, []);
return (
<LoadingOverlay
visible={renderMessage !== null}
message={renderMessage ?? 'Rendering...'}
/>
);
};