1068 lines
42 KiB
JavaScript
1068 lines
42 KiB
JavaScript
/**
|
||
* Audio Manager - Centralized Audio System for Gooner Training Academy
|
||
* Handles multiple audio categories with volume controls and fade effects
|
||
*/
|
||
class AudioManager {
|
||
constructor(dataManager, game = null) {
|
||
this.dataManager = dataManager;
|
||
this.game = game; // Reference to game instance for state checking
|
||
this.audioEnabled = true; // Global flag to disable all audio
|
||
this.isPlaylistMode = false; // New playlist mode flag
|
||
this.currentPlaylistAudio = null; // Current playlist audio element
|
||
this.playlistQueue = []; // Queue of audio files to play
|
||
this.currentPlaylistIndex = 0; // Current position in playlist
|
||
this.audioContext = null;
|
||
this.masterGain = null;
|
||
|
||
// Audio category configurations - simplified to just background audio
|
||
this.categories = {
|
||
background: {
|
||
volume: 0.7,
|
||
enabled: true,
|
||
currentAudio: null,
|
||
fadeTimeout: null
|
||
}
|
||
};
|
||
|
||
this.masterVolume = 0.7;
|
||
this.isInitialized = false;
|
||
this.audioLibrary = {};
|
||
|
||
this.init();
|
||
}
|
||
|
||
async init() {
|
||
console.log('AudioManager initializing...');
|
||
|
||
// Check if we're in a problematic browser environment
|
||
const isElectron = window.electronAPI !== undefined;
|
||
const isFileProtocol = window.location.protocol === 'file:';
|
||
|
||
if (!isElectron && isFileProtocol) {
|
||
console.warn('⚠️ Browser file:// mode detected - audio may not work due to CORS restrictions');
|
||
console.warn('💡 For full audio support, run: npm start (Electron desktop app)');
|
||
|
||
// Option to disable audio in browser mode
|
||
const disableAudio = localStorage.getItem('disableBrowserAudio') === 'true';
|
||
if (disableAudio) {
|
||
console.log('🔇 Audio disabled for browser mode');
|
||
this.isInitialized = true;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Load saved settings
|
||
this.loadSettings();
|
||
|
||
// Initialize Web Audio API for better control
|
||
try {
|
||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
this.masterGain = this.audioContext.createGain();
|
||
this.masterGain.connect(this.audioContext.destination);
|
||
this.masterGain.gain.value = this.masterVolume;
|
||
} catch (error) {
|
||
console.warn('Web Audio API not available, falling back to HTML5 audio:', error);
|
||
}
|
||
|
||
// Discover available audio files
|
||
await this.discoverAudioLibrary();
|
||
|
||
this.isInitialized = true;
|
||
console.log('AudioManager initialized successfully');
|
||
console.log('Available audio categories:', Object.keys(this.audioLibrary));
|
||
}
|
||
|
||
// Clean up corrupted audio data (paths with URL encoding issues)
|
||
async cleanupCorruptedAudioData() {
|
||
if (!window.game || !window.game.dataManager) {
|
||
return;
|
||
}
|
||
|
||
const customAudio = window.game.dataManager.get('customAudio') || { background: [], ambient: [] };
|
||
let hasCorruption = false;
|
||
|
||
// Check for and fix corrupted paths
|
||
['background', 'ambient'].forEach(category => {
|
||
if (customAudio[category]) {
|
||
customAudio[category] = customAudio[category].filter(audioFile => {
|
||
// Remove files with corrupted paths (containing URL encoding artifacts)
|
||
if (audioFile.path && audioFile.path.includes('%08')) {
|
||
console.log(`Removing corrupted audio file: ${audioFile.path}`);
|
||
hasCorruption = true;
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
});
|
||
|
||
// Remove any references to old 'effects' category
|
||
if (customAudio.effects) {
|
||
console.log('Removing deprecated effects category');
|
||
delete customAudio.effects;
|
||
hasCorruption = true;
|
||
}
|
||
|
||
if (hasCorruption) {
|
||
console.log('🧹 Cleaned up corrupted audio data');
|
||
window.game.dataManager.set('customAudio', customAudio);
|
||
}
|
||
}
|
||
|
||
async discoverAudioLibrary() {
|
||
console.log('🎵 Discovering audio library from user-managed files...');
|
||
|
||
// Clean up any corrupted audio data first
|
||
await this.cleanupCorruptedAudioData();
|
||
|
||
// Instead of hardcoded files, use the dynamic audio management system
|
||
// This integrates with the existing "Manage Audio" menu that users can control
|
||
|
||
// Initialize the library structure
|
||
this.audioLibrary = {
|
||
background: {
|
||
general: [] // Single category for all background audio
|
||
}
|
||
};
|
||
|
||
// Get audio files from the existing audio management system
|
||
if (window.game && window.game.dataManager) {
|
||
const customAudio = window.game.dataManager.get('customAudio') || { background: [], ambient: [] };
|
||
|
||
// Combine all audio categories into a single "background" category
|
||
const allAudioFiles = [
|
||
...(customAudio.background || []),
|
||
...(customAudio.ambient || [])
|
||
];
|
||
|
||
// Add user's audio files to the library (only if they actually exist)
|
||
const validFiles = [];
|
||
for (const audioFile of allAudioFiles) {
|
||
if (audioFile.enabled !== false) { // Only include enabled files
|
||
// Debug path corruption issue
|
||
console.log(`Processing audio file:`, audioFile);
|
||
console.log(`Path before adding:`, audioFile.path);
|
||
|
||
// Fix path corruption - decode any URL encoding and normalize path
|
||
let cleanPath = audioFile.path;
|
||
if (cleanPath.includes('%')) {
|
||
try {
|
||
cleanPath = decodeURIComponent(cleanPath);
|
||
console.log(`Decoded path: ${cleanPath}`);
|
||
} catch (e) {
|
||
console.warn(`Failed to decode path: ${cleanPath}`, e);
|
||
}
|
||
}
|
||
|
||
// For Electron with absolute Windows paths, keep them as-is
|
||
// Don't convert backslashes to forward slashes for Windows absolute paths
|
||
if (window.electronAPI && cleanPath.match(/^[A-Za-z]:\\/)) {
|
||
// Windows absolute path - convert backslashes to forward slashes for web compatibility
|
||
cleanPath = cleanPath.replace(/\\/g, '/');
|
||
console.log(`Normalized Windows path: ${cleanPath}`);
|
||
}
|
||
|
||
// For Electron, validate file exists before adding
|
||
if (window.electronAPI && window.electronAPI.fs) {
|
||
try {
|
||
const exists = await window.electronAPI.fs.existsSync(cleanPath.replace(/\//g, '\\'));
|
||
if (!exists) {
|
||
console.warn(`⚠️ File does not exist, skipping: ${cleanPath}`);
|
||
continue;
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Could not verify file existence: ${cleanPath}`, error);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const audioFileObj = {
|
||
name: audioFile.name || 'Unnamed Audio',
|
||
path: cleanPath,
|
||
element: null
|
||
};
|
||
|
||
this.audioLibrary.background.general.push(audioFileObj);
|
||
validFiles.push(audioFileObj);
|
||
console.log(`Added user audio file: ${cleanPath}`);
|
||
}
|
||
}
|
||
|
||
// Update storage to remove non-existent files
|
||
if (validFiles.length !== allAudioFiles.length && window.game && window.game.dataManager) {
|
||
console.log(`🧹 Removing ${allAudioFiles.length - validFiles.length} non-existent files from storage`);
|
||
const updatedCustomAudio = {
|
||
background: validFiles.filter(f => f.path.includes('/background/')),
|
||
ambient: validFiles.filter(f => f.path.includes('/ambient/'))
|
||
};
|
||
window.game.dataManager.set('customAudio', updatedCustomAudio);
|
||
}
|
||
|
||
console.log(`🎵 Loaded ${validFiles.length} user audio files`);
|
||
}
|
||
|
||
// If no user audio is available, create empty structure
|
||
if (this.audioLibrary.background.general.length === 0) {
|
||
console.log('🎵 No user audio files found.');
|
||
console.log('🎵 To add background audio:');
|
||
console.log(' 1. Go to the main menu');
|
||
console.log(' 2. Click "Manage Audio"');
|
||
console.log(' 3. Import audio files using the "Import" buttons');
|
||
console.log(' 4. Audio will automatically play during tasks');
|
||
}
|
||
|
||
console.log('🎵 Audio library discovery completed:', this.audioLibrary);
|
||
}
|
||
|
||
loadSettings() {
|
||
const savedSettings = this.dataManager.get('audioSettings') || {};
|
||
|
||
// Load master volume
|
||
this.masterVolume = savedSettings.masterVolume || 0.7;
|
||
|
||
// Load category settings
|
||
for (const category in this.categories) {
|
||
if (savedSettings.categories && savedSettings.categories[category]) {
|
||
this.categories[category] = {
|
||
...this.categories[category],
|
||
...savedSettings.categories[category]
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
saveSettings() {
|
||
const settings = {
|
||
masterVolume: this.masterVolume,
|
||
categories: this.categories
|
||
};
|
||
this.dataManager.set('audioSettings', settings);
|
||
}
|
||
|
||
// Master volume control
|
||
setMasterVolume(volume) {
|
||
this.masterVolume = Math.max(0, Math.min(1, volume));
|
||
if (this.masterGain) {
|
||
this.masterGain.gain.value = this.masterVolume;
|
||
}
|
||
this.saveSettings();
|
||
}
|
||
|
||
getMasterVolume() {
|
||
return this.masterVolume;
|
||
}
|
||
|
||
// Category volume control
|
||
setCategoryVolume(category, volume) {
|
||
if (this.categories[category]) {
|
||
this.categories[category].volume = Math.max(0, Math.min(1, volume));
|
||
|
||
// Update currently playing audio if any
|
||
if (this.categories[category].currentAudio) {
|
||
this.categories[category].currentAudio.volume =
|
||
this.categories[category].volume * this.masterVolume;
|
||
}
|
||
|
||
this.saveSettings();
|
||
}
|
||
}
|
||
|
||
getCategoryVolume(category) {
|
||
return this.categories[category]?.volume || 0;
|
||
}
|
||
|
||
// Enable/disable categories
|
||
setCategoryEnabled(category, enabled) {
|
||
if (this.categories[category]) {
|
||
this.categories[category].enabled = enabled;
|
||
|
||
// Stop currently playing audio if disabled
|
||
if (!enabled && this.categories[category].currentAudio) {
|
||
this.stopCategory(category);
|
||
}
|
||
|
||
this.saveSettings();
|
||
}
|
||
}
|
||
|
||
isCategoryEnabled(category) {
|
||
return this.categories[category]?.enabled || false;
|
||
}
|
||
|
||
// Play audio from library
|
||
async playAudio(category, subcategory, fileName = null, options = {}) {
|
||
console.log(`PlayAudio called: ${category}/${subcategory}/${fileName || 'random'}`);
|
||
|
||
// Check if audio is globally disabled
|
||
if (!this.audioEnabled) {
|
||
console.log('Audio globally disabled - blocking playback');
|
||
return null;
|
||
}
|
||
|
||
// Check if we're in a skip audio state (for consequences)
|
||
if (this.game && this.game.gameState && this.game.gameState.skipAudioPlayed) {
|
||
console.log('Skip audio played flag set - blocking background audio during consequence transition');
|
||
return null;
|
||
}
|
||
|
||
if (!this.isInitialized) {
|
||
console.warn('AudioManager not initialized yet');
|
||
return null;
|
||
}
|
||
|
||
// Check if we're in browser mode and warn about limitations
|
||
const isElectron = window.electronAPI !== undefined;
|
||
if (!isElectron) {
|
||
console.warn('Running in browser mode - audio may have limitations due to file:// restrictions');
|
||
}
|
||
|
||
// Check if the game is still running - prevent audio after game ends
|
||
if (this.game && this.game.gameState && !this.game.gameState.isRunning) {
|
||
console.log(`Game is not running - blocking audio playback for ${category}/${subcategory}`);
|
||
return null;
|
||
}
|
||
|
||
if (!this.isCategoryEnabled(category)) {
|
||
console.log(`Audio category ${category} is disabled`);
|
||
return null;
|
||
}
|
||
|
||
const categoryLib = this.audioLibrary[category];
|
||
if (!categoryLib || !categoryLib[subcategory]) {
|
||
console.warn(`Audio not found: ${category}/${subcategory}. Available:`, Object.keys(this.audioLibrary));
|
||
console.warn(`Category structure:`, categoryLib);
|
||
return null;
|
||
}
|
||
|
||
const audioFiles = categoryLib[subcategory];
|
||
if (audioFiles.length === 0) {
|
||
console.warn(`No audio files available in ${category}/${subcategory}`);
|
||
return null;
|
||
}
|
||
|
||
// Select audio file
|
||
let audioFile;
|
||
if (fileName) {
|
||
audioFile = audioFiles.find(f => f.name === fileName);
|
||
if (!audioFile) {
|
||
console.warn(`Specific audio file not found: ${fileName}. Available:`, audioFiles.map(f => f.name));
|
||
return null;
|
||
}
|
||
} else {
|
||
// Random selection
|
||
audioFile = audioFiles[Math.floor(Math.random() * audioFiles.length)];
|
||
}
|
||
|
||
console.log(`Selected audio file: ${audioFile.path}`);
|
||
|
||
// Validate the audio file path
|
||
if (!audioFile.path || audioFile.path.trim() === '') {
|
||
console.error(`Invalid audio file path: ${audioFile.path}`);
|
||
return null;
|
||
}
|
||
|
||
// Stop any currently playing audio in this category immediately and synchronously
|
||
if (this.categories[category].currentAudio) {
|
||
const existingAudio = this.categories[category].currentAudio;
|
||
existingAudio.pause();
|
||
existingAudio.currentTime = 0;
|
||
existingAudio._stopped = true; // Mark as stopped to prevent further playback
|
||
console.log(`[src debug] Stopping previous audio:`, existingAudio.src);
|
||
// Don't clear src as it causes corruption - just pause and reset time
|
||
// Also remove from DOM if it exists
|
||
if (existingAudio.parentNode) {
|
||
existingAudio.parentNode.removeChild(existingAudio);
|
||
}
|
||
this.categories[category].currentAudio = null;
|
||
console.log(`Forcibly stopped existing audio in category: ${category}`);
|
||
}
|
||
|
||
// Clear any pending fade timeout for this category
|
||
if (this.categories[category].fadeTimeout) {
|
||
clearTimeout(this.categories[category].fadeTimeout);
|
||
this.categories[category].fadeTimeout = null;
|
||
}
|
||
|
||
// Create and configure audio element
|
||
const audio = new Audio();
|
||
audio._stopped = false; // Flag to prevent orphaned playback
|
||
|
||
// Set the source with proper error handling
|
||
try {
|
||
console.log(`Setting audio source to: ${audioFile.path}`);
|
||
console.log(`Audio file object:`, audioFile);
|
||
|
||
// Debug: Check if path is already corrupted
|
||
if (audioFile.path.includes('%')) {
|
||
console.error(`Path already contains URL encoding: ${audioFile.path}`);
|
||
}
|
||
|
||
// For Electron, construct proper file:// URL for absolute paths
|
||
let audioSrc = audioFile.path;
|
||
if (window.electronAPI && audioFile.path.match(/^[A-Za-z]:\//)) {
|
||
// Absolute Windows path - convert to file:// URL
|
||
audioSrc = `file:///${audioFile.path}`;
|
||
console.log(`Converted to file URL: ${audioSrc}`);
|
||
} else if (window.electronAPI && !audioFile.path.startsWith('file://')) {
|
||
// Relative path in Electron - make it relative to app root
|
||
audioSrc = audioFile.path;
|
||
console.log(`Using relative path: ${audioSrc}`);
|
||
}
|
||
|
||
console.log(`[src debug] Setting audio.src to:`, audioSrc);
|
||
audio.src = audioSrc;
|
||
console.log(`[src debug] audio.src after assignment:`, audio.src);
|
||
|
||
} catch (error) {
|
||
console.error(`Error setting audio source: ${audioFile.path}`, error);
|
||
return null;
|
||
}
|
||
|
||
audio.volume = this.categories[category].volume * this.masterVolume;
|
||
audio.loop = options.loop || false;
|
||
|
||
console.log(`Audio volume set to: ${audio.volume} (category: ${this.categories[category].volume}, master: ${this.masterVolume})`);
|
||
|
||
// Add comprehensive error handling with retry logic
|
||
audio.addEventListener('error', (e) => {
|
||
console.error(`Audio file failed to load: ${audioFile.path}`, e);
|
||
console.error('Error details:', {
|
||
error: e.target.error,
|
||
networkState: e.target.networkState,
|
||
readyState: e.target.readyState,
|
||
src: e.target.src,
|
||
srcAttribute: audio.getAttribute('src'),
|
||
srcProperty: audio.src,
|
||
errorCode: e.target.error?.code,
|
||
errorMessage: e.target.error?.message
|
||
});
|
||
|
||
// Clean up failed audio element
|
||
if (this.categories[category].currentAudio === audio) {
|
||
this.categories[category].currentAudio = null;
|
||
}
|
||
|
||
// If this was a specific file request and it failed, don't retry
|
||
if (fileName) {
|
||
console.log(`Specific audio file ${fileName} failed, not retrying`);
|
||
return;
|
||
}
|
||
|
||
// For browser mode with file:// restrictions, disable retries to prevent spam
|
||
const isElectron = window.electronAPI !== undefined;
|
||
if (!isElectron && window.location.protocol === 'file:') {
|
||
console.warn('Browser file:// mode detected - disabling audio retries due to CORS restrictions');
|
||
return;
|
||
}
|
||
|
||
// For random selection, try a different file if available
|
||
const remainingFiles = audioFiles.filter(f => f !== audioFile);
|
||
if (remainingFiles.length > 0) {
|
||
console.log(`Retrying with different audio file from ${category}/${subcategory}`);
|
||
// Try again with a different random file after a short delay
|
||
setTimeout(() => {
|
||
// Check if game is still running before retrying
|
||
if (!window.game || !window.game.running) {
|
||
console.log('Game not running - canceling audio retry');
|
||
return;
|
||
}
|
||
this.playAudio(category, subcategory, null, options);
|
||
}, 500);
|
||
}
|
||
});
|
||
|
||
audio.addEventListener('loadstart', () => {
|
||
console.log(`Started loading: ${audioFile.path}`);
|
||
});
|
||
|
||
audio.addEventListener('canplay', () => {
|
||
if (audio._stopped) {
|
||
console.log(`Audio ready but stopped - not playing: ${audioFile.path}`);
|
||
return;
|
||
}
|
||
console.log(`Audio ready to play: ${audioFile.path}`);
|
||
});
|
||
|
||
audio.addEventListener('loadeddata', () => {
|
||
console.log(`Audio data loaded: ${audioFile.path}`);
|
||
});
|
||
|
||
// Set up event handlers
|
||
audio.addEventListener('ended', () => {
|
||
if (this.categories[category].currentAudio === audio) {
|
||
this.categories[category].currentAudio = null;
|
||
}
|
||
});
|
||
|
||
// Store reference
|
||
this.categories[category].currentAudio = audio;
|
||
|
||
// Use a small delay before attempting to play to ensure audio element is ready
|
||
try {
|
||
// First, load the audio
|
||
audio.load();
|
||
|
||
// Small delay to ensure the audio element is properly initialized
|
||
await new Promise(resolve => setTimeout(resolve, 50));
|
||
|
||
// Check if audio was stopped while loading
|
||
if (audio._stopped) {
|
||
console.log(`Audio was stopped while loading - not playing: ${audioFile.path}`);
|
||
return null;
|
||
}
|
||
|
||
await audio.play();
|
||
console.log(`Playing audio: ${category}/${subcategory}/${audioFile.name}`);
|
||
|
||
// Handle fade in
|
||
if (options.fadeIn) {
|
||
this.fadeIn(audio, options.fadeIn);
|
||
}
|
||
|
||
return audio;
|
||
} catch (error) {
|
||
console.error(`Failed to play audio: ${audioFile.path}`, error);
|
||
console.error(`Play error details:`, {
|
||
name: error.name,
|
||
message: error.message,
|
||
audioSrc: audio.src,
|
||
audioReadyState: audio.readyState,
|
||
audioNetworkState: audio.networkState
|
||
});
|
||
this.categories[category].currentAudio = null;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Stop audio in specific category
|
||
stopCategory(category, fadeOut = 0) {
|
||
if (!this.categories[category] || !this.categories[category].currentAudio) {
|
||
return;
|
||
}
|
||
|
||
const audio = this.categories[category].currentAudio;
|
||
console.log(`Stopping audio in category: ${category} with fadeOut: ${fadeOut}ms`);
|
||
if (fadeOut > 0) {
|
||
this.fadeOut(audio, fadeOut, () => {
|
||
audio.pause();
|
||
audio.currentTime = 0; // Reset to beginning
|
||
console.log(`[src debug] Clearing src of stopped audio:`, audio.src);
|
||
audio.src = '';
|
||
console.log(`[src debug] src after clearing:`, audio.src);
|
||
// Also remove from DOM if it exists
|
||
if (audio.parentNode) {
|
||
audio.parentNode.removeChild(audio);
|
||
}
|
||
this.categories[category].currentAudio = null;
|
||
console.log(`Audio stopped in category: ${category}`);
|
||
});
|
||
} else {
|
||
audio.pause();
|
||
audio.currentTime = 0; // Reset to beginning
|
||
audio._stopped = true; // Mark as stopped to prevent further playback
|
||
console.log(`[src debug] Stopping audio:`, audio.src);
|
||
// Don't clear src as it causes corruption - just pause and reset time
|
||
// Also remove from DOM if it exists
|
||
if (audio.parentNode) {
|
||
audio.parentNode.removeChild(audio);
|
||
}
|
||
this.categories[category].currentAudio = null;
|
||
console.log(`Audio immediately stopped in category: ${category}`);
|
||
}
|
||
|
||
// Clear any pending fade timeout
|
||
if (this.categories[category].fadeTimeout) {
|
||
clearTimeout(this.categories[category].fadeTimeout);
|
||
this.categories[category].fadeTimeout = null;
|
||
}
|
||
}
|
||
|
||
// Immediately stop all audio without fade
|
||
stopAllImmediate() {
|
||
console.log('Stopping all audio immediately...');
|
||
|
||
// Stop playlist first
|
||
this.stopPlaylist();
|
||
|
||
// Globally disable audio to prevent any new playback
|
||
this.audioEnabled = false;
|
||
|
||
for (const category in this.categories) {
|
||
if (this.categories[category].currentAudio) {
|
||
const audio = this.categories[category].currentAudio;
|
||
audio.pause();
|
||
audio.currentTime = 0;
|
||
audio._stopped = true; // Mark as stopped to prevent further playback
|
||
// Don't clear src as it causes corruption - just pause and reset
|
||
|
||
// Also remove from DOM if it exists
|
||
if (audio.parentNode) {
|
||
audio.parentNode.removeChild(audio);
|
||
}
|
||
|
||
this.categories[category].currentAudio = null;
|
||
console.log(`Forcibly stopped audio in category: ${category}`);
|
||
}
|
||
|
||
// Clear any pending fade timeouts
|
||
if (this.categories[category].fadeTimeout) {
|
||
clearTimeout(this.categories[category].fadeTimeout);
|
||
this.categories[category].fadeTimeout = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Re-enable audio (call when game starts)
|
||
enableAudio() {
|
||
console.log('Audio re-enabled');
|
||
this.audioEnabled = true;
|
||
|
||
// Clear any stopped audio elements to prevent orphaned events
|
||
for (const category in this.categories) {
|
||
if (this.categories[category].currentAudio && this.categories[category].currentAudio._stopped) {
|
||
// Remove stopped audio elements completely
|
||
const stoppedAudio = this.categories[category].currentAudio;
|
||
if (stoppedAudio.parentNode) {
|
||
stoppedAudio.parentNode.removeChild(stoppedAudio);
|
||
}
|
||
this.categories[category].currentAudio = null;
|
||
console.log(`Cleared stopped audio element in category: ${category}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Start continuous background audio playlist
|
||
startBackgroundPlaylist() {
|
||
if (!this.audioEnabled) {
|
||
console.log('Audio disabled - not starting playlist');
|
||
return;
|
||
}
|
||
|
||
// Get background audio files
|
||
const backgroundFiles = this.audioLibrary.background?.general || [];
|
||
if (backgroundFiles.length === 0) {
|
||
console.log('No background audio files available for playlist');
|
||
return;
|
||
}
|
||
|
||
// Shuffle the playlist
|
||
this.playlistQueue = [...backgroundFiles].sort(() => Math.random() - 0.5);
|
||
this.currentPlaylistIndex = 0;
|
||
this.isPlaylistMode = true;
|
||
|
||
console.log(`🎵 Starting background audio playlist with ${this.playlistQueue.length} tracks`);
|
||
this.playNextInPlaylist();
|
||
}
|
||
|
||
// Play next track in playlist
|
||
playNextInPlaylist() {
|
||
if (!this.isPlaylistMode || !this.audioEnabled) {
|
||
return;
|
||
}
|
||
|
||
if (this.playlistQueue.length === 0) {
|
||
console.log('Playlist is empty');
|
||
return;
|
||
}
|
||
|
||
// Stop current playlist audio if playing
|
||
if (this.currentPlaylistAudio) {
|
||
this.currentPlaylistAudio.pause();
|
||
this.currentPlaylistAudio.remove();
|
||
}
|
||
|
||
// Get next track (loop back to start if at end)
|
||
if (this.currentPlaylistIndex >= this.playlistQueue.length) {
|
||
this.currentPlaylistIndex = 0;
|
||
// Re-shuffle for variety
|
||
this.playlistQueue = [...this.playlistQueue].sort(() => Math.random() - 0.5);
|
||
console.log('🔄 Playlist completed, reshuffling and restarting');
|
||
}
|
||
|
||
const audioFile = this.playlistQueue[this.currentPlaylistIndex];
|
||
console.log(`🎵 Playing playlist track ${this.currentPlaylistIndex + 1}/${this.playlistQueue.length}: ${audioFile.name}`);
|
||
|
||
// Create audio element
|
||
this.currentPlaylistAudio = new Audio();
|
||
const audio = this.currentPlaylistAudio;
|
||
|
||
// Set up event handlers
|
||
audio.addEventListener('ended', () => {
|
||
console.log(`🎵 Track ended: ${audioFile.name}`);
|
||
this.currentPlaylistIndex++;
|
||
// Small delay before next track
|
||
setTimeout(() => this.playNextInPlaylist(), 500);
|
||
});
|
||
|
||
audio.addEventListener('error', (e) => {
|
||
console.error(`🎵 Playlist track failed: ${audioFile.name}`, e);
|
||
this.currentPlaylistIndex++;
|
||
// Try next track after error
|
||
setTimeout(() => this.playNextInPlaylist(), 1000);
|
||
});
|
||
|
||
// Set source and play
|
||
try {
|
||
// Use the same file URL conversion logic as the main playAudio method
|
||
let audioSrc = audioFile.path;
|
||
if (window.electronAPI && audioFile.path.match(/^[A-Za-z]:\//)) {
|
||
// Absolute Windows path - convert to file:// URL
|
||
audioSrc = `file:///${audioFile.path}`;
|
||
console.log(`🎵 Converted to file URL: ${audioSrc}`);
|
||
} else if (window.electronAPI && !audioFile.path.startsWith('file://')) {
|
||
// Relative path in Electron - make it relative to app root
|
||
audioSrc = audioFile.path;
|
||
console.log(`🎵 Using relative path: ${audioSrc}`);
|
||
}
|
||
|
||
audio.src = audioSrc;
|
||
audio.volume = this.getMasterVolume() * (this.getCategoryVolume('background') || 0.7);
|
||
audio.play().then(() => {
|
||
console.log(`🎵 Now playing: ${audioFile.name}`);
|
||
}).catch(error => {
|
||
console.error(`🎵 Failed to play playlist track: ${audioFile.name}`, error);
|
||
this.currentPlaylistIndex++;
|
||
setTimeout(() => this.playNextInPlaylist(), 1000);
|
||
});
|
||
} catch (error) {
|
||
console.error(`🎵 Error setting up playlist track: ${audioFile.name}`, error);
|
||
this.currentPlaylistIndex++;
|
||
setTimeout(() => this.playNextInPlaylist(), 1000);
|
||
}
|
||
}
|
||
|
||
// Pause playlist
|
||
pausePlaylist() {
|
||
if (this.currentPlaylistAudio && !this.currentPlaylistAudio.paused) {
|
||
this.currentPlaylistAudio.pause();
|
||
console.log('🎵 Playlist paused');
|
||
}
|
||
}
|
||
|
||
// Resume playlist
|
||
resumePlaylist() {
|
||
if (this.currentPlaylistAudio && this.currentPlaylistAudio.paused) {
|
||
this.currentPlaylistAudio.play().then(() => {
|
||
console.log('🎵 Playlist resumed');
|
||
}).catch(error => {
|
||
console.error('🎵 Failed to resume playlist', error);
|
||
// Try next track if current one fails
|
||
this.currentPlaylistIndex++;
|
||
setTimeout(() => this.playNextInPlaylist(), 500);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Stop playlist completely
|
||
stopPlaylist() {
|
||
this.isPlaylistMode = false;
|
||
if (this.currentPlaylistAudio) {
|
||
this.currentPlaylistAudio.pause();
|
||
this.currentPlaylistAudio.src = '';
|
||
this.currentPlaylistAudio.remove();
|
||
this.currentPlaylistAudio = null;
|
||
}
|
||
this.playlistQueue = [];
|
||
this.currentPlaylistIndex = 0;
|
||
console.log('🎵 Playlist stopped');
|
||
}
|
||
|
||
// Stop all audio
|
||
stopAll(fadeOut = 0) {
|
||
for (const category in this.categories) {
|
||
this.stopCategory(category, fadeOut);
|
||
}
|
||
}
|
||
|
||
// Fade effects
|
||
fadeIn(audio, duration) {
|
||
const targetVolume = audio.volume;
|
||
audio.volume = 0;
|
||
|
||
const steps = 20;
|
||
const stepSize = targetVolume / steps;
|
||
const stepDelay = duration / steps;
|
||
|
||
let currentStep = 0;
|
||
const fadeInterval = setInterval(() => {
|
||
currentStep++;
|
||
audio.volume = Math.min(targetVolume, stepSize * currentStep);
|
||
|
||
if (currentStep >= steps) {
|
||
clearInterval(fadeInterval);
|
||
audio.volume = targetVolume;
|
||
}
|
||
}, stepDelay);
|
||
}
|
||
|
||
fadeOut(audio, duration, callback = null) {
|
||
const startVolume = audio.volume;
|
||
|
||
const steps = 20;
|
||
const stepSize = startVolume / steps;
|
||
const stepDelay = duration / steps;
|
||
|
||
let currentStep = 0;
|
||
const fadeInterval = setInterval(() => {
|
||
currentStep++;
|
||
audio.volume = Math.max(0, startVolume - (stepSize * currentStep));
|
||
|
||
if (currentStep >= steps) {
|
||
clearInterval(fadeInterval);
|
||
audio.volume = 0;
|
||
if (callback) callback();
|
||
}
|
||
}, stepDelay);
|
||
}
|
||
|
||
// Get current playing status
|
||
isPlaying(category) {
|
||
return this.categories[category]?.currentAudio &&
|
||
!this.categories[category].currentAudio.paused;
|
||
}
|
||
|
||
getCurrentlyPlaying() {
|
||
const playing = {};
|
||
for (const [category, config] of Object.entries(this.categories)) {
|
||
if (this.isPlaying(category)) {
|
||
playing[category] = {
|
||
src: config.currentAudio.src,
|
||
currentTime: config.currentAudio.currentTime,
|
||
duration: config.currentAudio.duration
|
||
};
|
||
}
|
||
}
|
||
return playing;
|
||
}
|
||
|
||
// Audio library info
|
||
getAvailableAudio() {
|
||
const available = {};
|
||
for (const [category, subcategories] of Object.entries(this.audioLibrary)) {
|
||
available[category] = {};
|
||
for (const [subcategory, files] of Object.entries(subcategories)) {
|
||
available[category][subcategory] = files.map(f => f.name);
|
||
}
|
||
}
|
||
return available;
|
||
}
|
||
|
||
// Preview audio (for settings menu)
|
||
async previewAudio(category, subcategory, fileName) {
|
||
// Stop any currently playing preview
|
||
this.stopCategory('preview');
|
||
|
||
// Play audio with temporary category
|
||
const audio = await this.playAudio(category, subcategory, fileName);
|
||
if (audio) {
|
||
// Auto-stop after 3 seconds for preview
|
||
setTimeout(() => {
|
||
if (audio && !audio.paused) {
|
||
audio.pause();
|
||
}
|
||
}, 3000);
|
||
}
|
||
return audio;
|
||
}
|
||
|
||
// Test audio playback - useful for debugging
|
||
async testAudio() {
|
||
console.log('Testing audio system...');
|
||
console.log('Audio library:', this.audioLibrary);
|
||
|
||
// Test a teasing audio file
|
||
if (this.audioLibrary.tasks && this.audioLibrary.tasks.teasing && this.audioLibrary.tasks.teasing.length > 0) {
|
||
console.log('Testing teasing audio...');
|
||
await this.playAudio('tasks', 'teasing');
|
||
} else {
|
||
console.log('No teasing audio files found');
|
||
}
|
||
}
|
||
|
||
// Convenient methods for specific audio types - now all use background audio
|
||
playTaskAudio(intensity = 'general', options = {}) {
|
||
return this.playBackgroundAudio(options);
|
||
}
|
||
|
||
playPunishmentAudio(type = 'general', options = {}) {
|
||
return this.playBackgroundAudio(options);
|
||
}
|
||
|
||
playRewardAudio(options = {}) {
|
||
return this.playBackgroundAudio(options);
|
||
}
|
||
|
||
playInstructionAudio(instruction, options = {}) {
|
||
return this.playBackgroundAudio(options);
|
||
}
|
||
|
||
// Main background audio playing method
|
||
playBackgroundAudio(options = {}) {
|
||
return this.playAudio('background', 'general', null, options);
|
||
}
|
||
|
||
// Enhanced playAudio with fallback support - simplified since we only have one category
|
||
async playAudioWithFallback(category, primarySubcategory, fallbackSubcategory, fileName = null, options = {}) {
|
||
try {
|
||
return await this.playAudio(category, primarySubcategory, fileName, options);
|
||
} catch (error) {
|
||
console.log(`Audio playback failed for ${category}/${primarySubcategory}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Scan audio directories for files
|
||
async scanAudioDirectories() {
|
||
console.log('🔍 Scanning audio directories...');
|
||
|
||
const scannedFiles = [];
|
||
|
||
// Simplified directory structure - only background and ambient
|
||
const audioDirectories = [
|
||
'audio/background',
|
||
'audio/ambient'
|
||
];
|
||
|
||
// For Electron environment, we can use the existing readAudioDirectory API
|
||
if (window.electronAPI && window.electronAPI.readAudioDirectory) {
|
||
try {
|
||
for (const directory of audioDirectories) {
|
||
console.log(`Scanning directory: ${directory}`);
|
||
|
||
// Get the full path to the directory
|
||
const appPath = await window.electronAPI.getAppPath();
|
||
const fullPath = await window.electronAPI.pathJoin(appPath, directory);
|
||
|
||
// Check if directory exists before trying to read it
|
||
const dirExists = await window.electronAPI.fileExists(fullPath);
|
||
if (!dirExists) {
|
||
console.log(`Directory does not exist: ${fullPath}`);
|
||
continue;
|
||
}
|
||
|
||
const files = await window.electronAPI.readAudioDirectory(fullPath);
|
||
|
||
for (const file of files) {
|
||
scannedFiles.push({
|
||
name: file.name,
|
||
path: file.path,
|
||
title: file.title || file.name,
|
||
directory: directory,
|
||
enabled: true
|
||
});
|
||
}
|
||
}
|
||
|
||
console.log(`🔍 Found ${scannedFiles.length} audio files in directories`);
|
||
|
||
// Add scanned files to the audio library if they're not already in customAudio
|
||
if (scannedFiles.length > 0) {
|
||
await this.addScannedFilesToLibrary(scannedFiles);
|
||
}
|
||
|
||
return scannedFiles;
|
||
|
||
} catch (error) {
|
||
console.error('Error scanning audio directories:', error);
|
||
return [];
|
||
}
|
||
} else {
|
||
console.warn('Directory scanning not available - requires Electron environment');
|
||
console.log('💡 To use directory scanning:');
|
||
console.log(' 1. Run the app with: npm start');
|
||
console.log(' 2. Or manually add audio files via "Manage Audio" menu');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Add scanned files to the audio library
|
||
async addScannedFilesToLibrary(scannedFiles) {
|
||
if (!window.game || !window.game.dataManager) {
|
||
console.warn('DataManager not available for adding scanned files');
|
||
return;
|
||
}
|
||
|
||
const customAudio = window.game.dataManager.get('customAudio') || {
|
||
background: [],
|
||
ambient: []
|
||
};
|
||
|
||
let addedCount = 0;
|
||
|
||
for (const file of scannedFiles) {
|
||
// Check if file is already in the library
|
||
const existsInBackground = customAudio.background.some(f => f.path === file.path);
|
||
const existsInAmbient = customAudio.ambient.some(f => f.path === file.path);
|
||
|
||
if (!existsInBackground && !existsInAmbient) {
|
||
// Determine which category to add to based on directory
|
||
let category = 'background'; // Default category
|
||
|
||
if (file.directory.includes('ambient')) {
|
||
category = 'ambient';
|
||
}
|
||
// Note: Since we removed effects, tasks, etc., everything else goes to background
|
||
|
||
customAudio[category].push({
|
||
name: file.name,
|
||
path: file.path,
|
||
enabled: true,
|
||
source: 'directory_scan'
|
||
});
|
||
|
||
addedCount++;
|
||
console.log(`Added ${file.name} to ${category} category`);
|
||
}
|
||
}
|
||
|
||
if (addedCount > 0) {
|
||
// Save updated audio library
|
||
window.game.dataManager.set('customAudio', customAudio);
|
||
|
||
// Refresh the audio library to include new files
|
||
await this.discoverAudioLibrary();
|
||
|
||
console.log(`✅ Added ${addedCount} new audio files to library`);
|
||
|
||
// Show success message to user
|
||
if (window.game && window.game.flashMessageManager) {
|
||
window.game.flashMessageManager.show(
|
||
`Audio Scan Complete: Added ${addedCount} audio files to library`,
|
||
'success'
|
||
);
|
||
}
|
||
} else {
|
||
console.log('ℹ️ No new audio files found during scan');
|
||
|
||
if (window.game && window.game.flashMessageManager) {
|
||
window.game.flashMessageManager.show(
|
||
'Audio scan complete - no new files found',
|
||
'info'
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Refresh audio library - useful when user adds new audio files
|
||
async refreshAudioLibrary() {
|
||
console.log('🎵 Refreshing audio library...');
|
||
await this.discoverAudioLibrary();
|
||
console.log('🎵 Audio library refreshed');
|
||
}
|
||
|
||
// Get settings for UI
|
||
getSettings() {
|
||
return {
|
||
masterVolume: this.masterVolume,
|
||
categories: { ...this.categories },
|
||
available: this.getAvailableAudio(),
|
||
isInitialized: this.isInitialized
|
||
};
|
||
}
|
||
|
||
// Public method to trigger directory scan (for UI buttons)
|
||
async triggerDirectoryScan() {
|
||
console.log('🔍 Triggering audio directory scan from UI...');
|
||
return await this.scanAudioDirectories();
|
||
}
|
||
} |