{"repo": "biohaphazard/freedify", "files": {"static/integrations.js": {"d175e5bd64980366f5bc3583264a950ecd5d9eca": {"t": 1785768937.6560652, "n": 94216, "h": "a029af57bea59db5", "c": "// ========== INTEGRATIONS MODULE ==========\n// Media Session, Google Drive sync, Local files, AI Radio, Add-to-playlist modal,\n// ListenBrainz/Last.fm scrobbling, Recommendations, Mini Player (Winamp PiP),\n// AI Assistant, Spotify OAuth\n\nimport { state } from './state.js';\nimport { emit, on } from './event-bus.js';\nimport { showToast, escapeHtml, formatTime } from './utils.js';\nimport { $, $$, audioPlayer, audioPlayer2, domState, searchInput, resultsContainer, resultsSection, detailView, queueSection, volumeSlider } from './dom.js';\nimport { showLoading, hideLoading, showError, renderMoodSelector } from './ui.js';\nimport { audio, getActivePlayer } from './audio-engine.js';\nimport { enableSync, disableSync, discoverDevices } from './sync.js';\nimport { savePlaylists, createPlaylist, addToPlaylist, saveLibrary, saveHistory,\n savePodcastFavorites, saveAudiobookFavorites, savePodcastPlayed,\n savePodcastResumePositions, savePodcastHistory, saveAudiobookHistory,\n savePodcastTags, getMoodPreferences } from './data.js';\n\n// ========== MEDIA SESSION API (Lock Screen Controls) ==========\n\nfunction updateMediaSession(track) {\n if (!('mediaSession' in navigator)) return;\n\n const artworkSrc = track.album_art || '/static/icon.svg';\n navigator.mediaSession.metadata = new MediaMetadata({\n title: track.name || 'Unknown Track',\n artist: track.artists || 'Unknown Artist',\n album: track.album || '',\n artwork: [\n { src: artworkSrc, sizes: '96x96', type: 'image/png' },\n { src: artworkSrc, sizes: '128x128', type: 'image/png' },\n { src: artworkSrc, sizes: '192x192', type: 'image/png' },\n { src: artworkSrc, sizes: '256x256', type: 'image/png' },\n { src: artworkSrc, sizes: '384x384', type: 'image/png' },\n { src: artworkSrc, sizes: '512x512', type: 'image/png' }\n ]\n });\n navigator.mediaSession.playbackState = 'playing';\n}\n\n// Register action handlers at page load — they persist across playbacks (per web.dev spec)\nif ('mediaSession' in navigator) {\n const actionHandlers = [\n ['play', async () => {\n if (audio.audioContext?.state === 'suspended') {\n await audio.audioContext.resume();\n }\n await getActivePlayer().play().catch(e => console.warn('MediaSession play failed:', e));\n }],\n ['pause', () => {\n state.isPlaying = false;\n getActivePlayer().pause();\n }],\n ['previoustrack', () => {\n emit('playPrevious');\n }],\n ['nexttrack', () => {\n emit('playNext');\n }],\n ['seekbackward', (details) => {\n const player = getActivePlayer();\n player.currentTime = Math.max(player.currentTime - (details.seekOffset || 10), 0);\n }],\n ['seekforward', (details) => {\n const player = getActivePlayer();\n player.currentTime = Math.min(player.currentTime + (details.seekOffset || 10), player.duration);\n }],\n ['seekto', (details) => {\n const player = getActivePlayer();\n if (details.fastSeek && 'fastSeek' in player) {\n player.fastSeek(details.seekTime);\n } else {\n player.currentTime = details.seekTime;\n }\n }],\n ['stop', () => {\n getActivePlayer().pause();\n getActivePlayer().currentTime = 0;\n state.isPlaying = false;\n emit('updatePlayButton');\n navigator.mediaSession.playbackState = 'none';\n }]\n ];\n\n for (const [action, handler] of actionHandlers) {\n try {\n navigator.mediaSession.setActionHandler(action, handler);\n } catch (error) {\n }\n }\n}\n\n// Update position state for lock screen on BOTH players\nfunction updateMediaSessionPosition() {\n if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {\n try {\n const player = getActivePlayer();\n if (player.duration && !isNaN(player.duration) && player.duration > 0) {\n navigator.mediaSession.setPositionState({\n duration: player.duration,\n playbackRate: player.playbackRate,\n position: Math.min(player.currentTime, player.duration)\n });\n }\n } catch (e) { /* Ignore errors */ }\n }\n}\naudioPlayer.addEventListener('timeupdate', updateMediaSessionPosition);\naudioPlayer2.addEventListener('timeupdate', updateMediaSessionPosition);\n\n// ========== ANDROID BACKGROUND PLAYBACK RESILIENCE ==========\n\n// Resume AudioContext and clear stale guards when page becomes visible\ndocument.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'visible') {\n // Resume main AudioContext if suspended while playing\n if (audio.audioContext?.state === 'suspended' && state.isPlaying) {\n audio.audioContext.resume();\n }\n // Clear stale crossfade guard that may have been frozen\n if (audio.crossfadeTimeout) {\n clearTimeout(audio.crossfadeTimeout);\n audio.crossfadeTimeout = null;\n }\n // If we should be playing but audio is paused, try to resume\n const player = getActivePlayer();\n if (state.isPlaying && player.paused && player.readyState >= 2) {\n player.play().catch(() => {});\n }\n }\n});\n\n// Recover from network drops in background\nwindow.addEventListener('online', () => {\n const player = getActivePlayer();\n if (state.isPlaying && (player.paused || player.readyState < 3)) {\n const pos = player.currentTime;\n player.currentTime = pos; // Force reconnect by seeking to current position\n player.play().catch(() => {});\n }\n});\n\n// ========== GOOGLE DRIVE SYNC ==========\n// Client ID: fetched from server env var, or from localStorage, or prompted\nlet GOOGLE_CLIENT_ID = localStorage.getItem('freedify_google_client_id') || '';\n// Expanded scope: appdata for favorites sync + drive.file for saving audio files\nconst GOOGLE_SCOPES = 'https://www.googleapis.com/auth/drive.appdata https://www.googleapis.com/auth/drive.file';\nconst SYNC_FILENAME = 'freedify_playlists.json';\nconst FREEDIFY_FOLDER_NAME = 'Freedify';\n\nlet googleAccessToken = null;\n\n// Fetch server-side config (Google Client ID from env vars)\n(async function loadServerConfig() {\n try {\n const res = await fetch('/api/config');\n if (res.ok) {\n const config = await res.json();\n if (config.google_client_id) {\n GOOGLE_CLIENT_ID = config.google_client_id;\n }\n }\n } catch (e) {\n }\n})();\nconst syncBtn = $('#sync-btn');\n\n// Initialize Google API\n// Initialize Google API\nwindow.initGoogleApi = function() {\n return new Promise((resolve) => {\n if (typeof gapi === 'undefined') {\n resolve(false);\n return;\n }\n gapi.load('client', async () => {\n try {\n await gapi.client.init({\n discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest']\n });\n resolve(true);\n } catch (e) {\n console.error('Failed to init Google API:', e);\n resolve(false);\n }\n });\n });\n};\n\n// If gapi is already loaded (race condition), init immediately\nif (typeof gapi !== 'undefined') {\n window.initGoogleApi();\n}\n\n// Google Sign-In\nasync function signInWithGoogle() {\n if (!GOOGLE_CLIENT_ID) {\n const clientId = prompt(\n 'Enter your Google OAuth Client ID:\\n\\n' +\n 'To get one:\\n' +\n '1. Go to console.cloud.google.com\\n' +\n '2. Create a project\\n' +\n '3. Enable Drive API\\n' +\n '4. Create OAuth credentials (Web application)\\n' +\n '5. Add your domain to authorized origins'\n );\n if (clientId) {\n localStorage.setItem('freedify_google_client_id', clientId);\n location.reload();\n }\n return null;\n }\n\n return new Promise((resolve) => {\n const client = google.accounts.oauth2.initTokenClient({\n client_id: GOOGLE_CLIENT_ID,\n scope: GOOGLE_SCOPES,\n callback: (response) => {\n if (response.access_token) {\n googleAccessToken = response.access_token;\n gapi.client.setToken({ access_token: googleAccessToken });\n syncBtn.classList.add('synced');\n showToast('Signed in to Google Drive');\n resolve(response.access_token);\n } else {\n resolve(null);\n }\n },\n error_callback: (error) => {\n console.error('Google sign-in error:', error);\n showToast('Sign-in failed');\n resolve(null);\n }\n });\n client.requestAccessToken();\n });\n}\n\n// Find sync file in Drive\nasync function findSyncFile() {\n try {\n const response = await gapi.client.drive.files.list({\n spaces: 'appDataFolder',\n q: `name='${SYNC_FILENAME}'`,\n fields: 'files(id, name, modifiedTime)'\n });\n return response.result.files?.[0] || null;\n } catch (e) {\n console.error('Error finding sync file:', e);\n return null;\n }\n}\n\n// Find or create \"Freedify\" folder in Drive root\nasync function findOrCreateFreedifyFolder() {\n try {\n // Search for existing folder\n const response = await gapi.client.drive.files.list({\n q: `name='${FREEDIFY_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,\n fields: 'files(id, name)'\n });\n\n if (response.result.files && response.result.files.length > 0) {\n return response.result.files[0].id;\n }\n\n // Create folder if not found\n const createResponse = await gapi.client.drive.files.create({\n resource: {\n name: FREEDIFY_FOLDER_NAME,\n mimeType: 'application/vnd.google-apps.folder'\n },\n fields: 'id'\n });\n\n return createResponse.result.id;\n } catch (e) {\n console.error('Error finding/creating folder:', e);\n return null;\n }\n}\n\n// Upload to Drive with Granular Support\n// syncType: 'all', 'playlists', 'queue'\nasync function uploadToDrive(syncType = 'all') {\n if (!googleAccessToken) {\n await signInWithGoogle();\n if (!googleAccessToken) return; // User cancelled auth\n }\n\n const loadingText = syncType === 'all' ? 'Syncing all to Drive...' :\n syncType === 'playlists' ? 'Syncing playlists...' : 'Syncing queue...';\n showLoading(loadingText);\n\n try {\n // 1. Fetch EXISTING file first to preserve data we aren't updating\n const existingFile = await findSyncFile();\n let currentRemoteData = {};\n\n if (existingFile) {\n try {\n const response = await fetch(\n `https://www.googleapis.com/drive/v3/files/${existingFile.id}?alt=media`,\n { headers: { 'Authorization': `Bearer ${googleAccessToken}` } }\n );\n if (response.ok) {\n const json = await response.json();\n // Handle legacy array format\n if (Array.isArray(json)) {\n currentRemoteData = { playlists: json, queue: [] };\n } else {\n currentRemoteData = json;\n }\n }\n } catch (err) {\n console.warn('Failed to read existing sync data, starting fresh', err);\n }\n }\n\n // 2. Prepare NEW data by merging state into remote data\n const syncData = {\n playlists: currentRemoteData.playlists || [],\n library: currentRemoteData.library || [],\n history: currentRemoteData.history || [],\n queue: currentRemoteData.queue || [],\n currentIndex: currentRemoteData.currentIndex || 0,\n volume: currentRemoteData.volume || 1,\n syncedAt: new Date().toISOString()\n };\n\n if (syncType === 'all' || syncType === 'playlists') {\n syncData.playlists = state.playlists;\n syncData.library = state.library;\n syncData.history = state.history;\n syncData.podcastFavorites = state.podcastFavorites;\n syncData.audiobookFavorites = state.audiobookFavorites;\n syncData.podcastPlayedEpisodes = state.podcastPlayedEpisodes;\n syncData.podcastResumePositions = state.podcastResumePositions;\n syncData.podcastHistory = state.podcastHistory;\n syncData.audiobookHistory = state.audiobookHistory;\n syncData.podcastTags = state.podcastTags;\n syncData.moodHistory = state.moodHistory;\n syncData.moodPreferences = state.moodPreferences;\n syncData.watchedPlaylists = state.watchedPlaylists;\n }\n\n if (syncType === 'all' || syncType === 'queue') {\n syncData.queue = state.queue;\n syncData.currentIndex = state.currentIndex;\n syncData.volume = state.volume;\n }\n\n // 3. Upload\n const fileContent = JSON.stringify(syncData, null, 2);\n\n const metadata = {\n name: SYNC_FILENAME,\n mimeType: 'application/json'\n };\n\n if (!existingFile) {\n metadata.parents = ['appDataFolder'];\n }\n\n const form = new FormData();\n form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));\n form.append('file', new Blob([fileContent], { type: 'application/json' }));\n\n const url = existingFile\n ? `https://www.googleapis.com/upload/drive/v3/files/${existingFile.id}?uploadType=multipart`\n : 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';\n\n const response = await fetch(url, {\n method: existingFile ? 'PATCH' : 'POST',\n headers: { 'Authorization': `Bearer ${googleAccessToken}` },\n body: form\n });\n\n if (response.ok) {\n hideLoading();\n // Close modal if open\n $('#drive-sync-modal').classList.add('hidden');\n\n let msg = 'Sync Complete!';\n if (syncType === 'playlists') msg = `Synced ${state.playlists.length} playlists to Drive`;\n if (syncType === 'queue') msg = `Synced queue (${state.queue.length} tracks)`;\n if (syncType === 'all') msg = `Synced Match & Queue to Drive`;\n\n showToast(msg);\n localStorage.setItem('freedify_last_sync', new Date().toISOString());\n } else {\n throw new Error('Upload failed');\n }\n } catch (e) {\n console.error('Upload error:', e);\n hideLoading();\n showError('Failed to sync to Google Drive');\n }\n}\n\n// Download from Drive with Granular Support\nasync function downloadFromDrive(syncType = 'all') {\n if (!googleAccessToken) {\n await signInWithGoogle();\n if (!googleAccessToken) return;\n }\n\n showLoading('Loading from Google Drive...');\n\n try {\n const file = await findSyncFile();\n\n if (!file) {\n hideLoading();\n showToast('No saved data found in Drive');\n return;\n }\n\n const response = await fetch(\n `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`,\n { headers: { 'Authorization': `Bearer ${googleAccessToken}` } }\n );\n\n if (response.ok) {\n const syncData = await response.json();\n\n // Normalize data (handle legacy)\n const remotePlaylists = Array.isArray(syncData) ? syncData : (syncData.playlists || []);\n const remoteLibrary = Array.isArray(syncData) ? [] : (syncData.library || []);\n const remoteHistory = Array.isArray(syncData) ? [] : (syncData.history || []);\n const remoteQueue = Array.isArray(syncData) ? [] : (syncData.queue || []);\n const remoteIndex = Array.isArray(syncData) ? 0 : (syncData.currentIndex || 0);\n\n let restoredCount = 0;\n\n // Apply updates\n if (syncType === 'all' || syncType === 'playlists') {\n state.playlists = remotePlaylists;\n savePlaylists();\n\n // Restore library\n if (remoteLibrary.length > 0) {\n state.library = remoteLibrary;\n saveLibrary();\n }\n\n // Restore history\n if (remoteHistory.length > 0) {\n state.history = remoteHistory;\n saveHistory();\n }\n\n // Restore podcast data\n if (syncData.podcastFavorites) {\n state.podcastFavorites = syncData.podcastFavorites;\n savePodcastFavorites();\n }\n\n if (syncData.audiobookFavorites) {\n state.audiobookFavorites = syncData.audiobookFavorites;\n saveAudiobookFavorites();\n }\n\n if (syncData.podcastPlayedEpisodes) {\n state.podcastPlayedEpisodes = syncData.podcastPlayedEpisodes;\n savePodcastPlayed();\n }\n if (syncData.podcastResumePositions) {\n state.podcastResumePositions = syncData.podcastResumePositions;\n savePodcastResumePositions();\n }\n if (syncData.podcastHistory) {\n state.podcastHistory = syncData.podcastHistory;\n savePodcastHistory();\n }\n if (syncData.audiobookHistory) {\n state.audiobookHistory = syncData.audiobookHistory;\n saveAudiobookHistory();\n }\n if (syncData.podcastTags) {\n state.podcastTags = syncData.podcastTags;\n savePodcastTags();\n }\n\n // Restore mood data (optional keys — skip if absent)\n if (syncData.moodHistory) {\n state.moodHistory = syncData.moodHistory;\n localStorage.setItem('freedify_mood_history', JSON.stringify(state.moodHistory));\n }\n if (syncData.moodPreferences) {\n state.moodPreferences = syncData.moodPreferences;\n localStorage.setItem('freedify_mood_preferences', JSON.stringify(state.moodPreferences));\n }\n if (syncData.watchedPlaylists) {\n state.watchedPlaylists = syncData.watchedPlaylists;\n localStorage.setItem('freedify_watched', JSON.stringify(state.watchedPlaylists));\n }\n\n restoredCount = remotePlaylists.length;\n // If favorites view is active, refresh it\n if (state.searchType === 'favorites') emit('renderPlaylistsView');\n }\n\n if (syncType === 'all' || syncType === 'queue') {\n if (remoteQueue.length > 0) {\n state.queue = remoteQueue;\n state.currentIndex = remoteIndex;\n // Use remote volume only if 'all' to avoid startling volume jumps on just queue sync?\n // Let's stick to syncing volume on queue sync.\n if (syncData.volume) {\n state.volume = syncData.volume;\n if (audioPlayer) audioPlayer.volume = state.volume;\n if (audioPlayer2) audioPlayer2.volume = state.volume;\n if (volumeSlider) volumeSlider.value = Math.round(state.volume * 100);\n }\n emit('updateQueueUI');\n emit('updatePlayerUI');\n }\n }\n\n hideLoading();\n // Close modal\n $('#drive-sync-modal').classList.add('hidden');\n\n if (syncType === 'playlists') showToast(`Loaded ${restoredCount} playlists`);\n else if (syncType === 'queue') showToast(`Loaded queue (${remoteQueue.length} tracks)`);\n else showToast(`Loaded Library & Session`);\n\n } else {\n throw new Error('Download failed');\n }\n } catch (e) {\n console.error('Download error:', e);\n hideLoading();\n showError('Failed to load from Google Drive');\n }\n}\n\n// --- Drive Sync Modal UI & Events ---\n\nfunction updateDriveModalUI() {\n const authSection = $('#drive-auth-section');\n const optionsSection = $('#drive-options-section');\n const userEmailSpan = $('#drive-user-email');\n\n // Check if we have a valid access token (GIS flow stores it in googleAccessToken)\n if (googleAccessToken) {\n authSection.classList.add('hidden');\n optionsSection.classList.remove('hidden');\n if (userEmailSpan) userEmailSpan.textContent = 'Connected to Google Drive';\n } else {\n authSection.classList.remove('hidden');\n optionsSection.classList.add('hidden');\n }\n}\n\nasync function showDriveModal() {\n const modal = $('#drive-sync-modal');\n modal.classList.remove('hidden');\n\n // Ensure API is ready\n if (typeof gapi !== 'undefined' && (!gapi.auth2 || !gapi.client.drive)) {\n $('#drive-loading').classList.remove('hidden');\n await initGoogleApi();\n $('#drive-loading').classList.add('hidden');\n }\n updateDriveModalUI();\n}\n\n// Open Modal\nsyncBtn?.addEventListener('click', () => {\n // Close settings modal first so Drive modal isn't obscured\n document.getElementById('settings-modal')?.classList.add('hidden');\n showDriveModal();\n});\n\n// Close Modal\n$('#drive-modal-close')?.addEventListener('click', () => $('#drive-sync-modal').classList.add('hidden'));\n$('#drive-modal-close-top')?.addEventListener('click', () => $('#drive-sync-modal').classList.add('hidden'));\n\n// Auth Buttons\n$('#drive-signin-btn')?.addEventListener('click', async () => {\n await signInWithGoogle();\n updateDriveModalUI();\n});\n\n$('#drive-signout-btn')?.addEventListener('click', () => {\n googleAccessToken = null;\n gapi.client.setToken(null);\n syncBtn?.classList.remove('synced');\n updateDriveModalUI();\n showToast('Signed out from Google Drive');\n});\n\n// Granular Action Bindings\n$('#drive-up-all')?.addEventListener('click', () => uploadToDrive('all'));\n$('#drive-up-playlists')?.addEventListener('click', () => uploadToDrive('playlists'));\n$('#drive-up-queue')?.addEventListener('click', () => uploadToDrive('queue'));\n\n$('#drive-down-all')?.addEventListener('click', () => downloadFromDrive('all'));\n$('#drive-down-playlists')?.addEventListener('click', () => downloadFromDrive('playlists'));\n$('#drive-down-queue')?.addEventListener('click', () => downloadFromDrive('queue'));\n\nfunction initGoogleDriveSync() {\n // All event listeners are already bound above at module scope.\n // This function exists as a named init entry point for the main orchestrator.\n}\n\n// ========== LOCAL FILE HANDLING ==========\nfunction initLocalFiles() {\n initDragAndDrop();\n initManualUpload();\n}\n\nfunction initDragAndDrop() {\n // Attach to window to catch drops anywhere\n const dropZone = window;\n\n ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {\n dropZone.addEventListener(eventName, preventDefaults, false);\n });\n\n function preventDefaults(e) {\n e.preventDefault();\n e.stopPropagation();\n }\n\n // Highlight drop zone (using body class)\n window.addEventListener('dragenter', () => document.body.classList.add('dragging'), false);\n window.addEventListener('dragleave', (e) => {\n // Only remove if leaving the window\n if (e.clientX === 0 && e.clientY === 0) {\n document.body.classList.remove('dragging');\n }\n }, false);\n\n window.addEventListener('drop', (e) => {\n document.body.classList.remove('dragging');\n handleDrop(e);\n }, false);\n\n}\n\nfunction initManualUpload() {\n // const addLocalBtn = document.getElementById('add-local-btn'); // Replaced by Label\n const fileInput = document.getElementById('file-input');\n\n if (fileInput) {\n // No click listener needed for Label\n\n fileInput.addEventListener('change', (e) => {\n if (e.target.files && e.target.files.length > 0) {\n handleFiles(e.target.files);\n }\n });\n } else {\n console.error(\"Could not find add-local-btn or file-input\");\n }\n}\n\nfunction handleDrop(e) {\n const dt = e.dataTransfer;\n const files = dt.files;\n handleFiles(files);\n}\n\nasync function handleFiles(files) {\n // alert(`DEBUG: handleFiles called with ${files.length} files`);\n\n const validExtensions = ['.mp3', '.flac', '.wav', '.aiff', '.aac', '.ogg', '.m4a', '.wma'];\n\n const audioFiles = Array.from(files).filter(file => {\n const isAudio = file.type.startsWith('audio/') ||\n // Fallback: check extension if type is empty or generic\n validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));\n return isAudio;\n });\n\n // DEBUG: Log files\n // console.log(\"All files:\", files);\n\n if (audioFiles.length === 0) {\n if (files.length > 0) showToast('No supported audio files found', 'error');\n return;\n }\n\n showLoading(`Processing ${audioFiles.length} local files...`);\n\n let processedCount = 0;\n for (const file of audioFiles) {\n try {\n const metadata = await extractMetadata(file);\n if (metadata) {\n addLocalTrackToQueue(file, metadata);\n processedCount++;\n }\n } catch (err) {\n console.error('Error processing file:', file.name, err);\n showToast(`Error reading ${file.name}`, 'error');\n }\n }\n\n hideLoading();\n\n if (processedCount > 0) {\n showToast(`Added ${processedCount} local tracks to queue!`, 'success');\n emit('updateQueueUI');\n if (!state.isPlaying && state.queue.length === processedCount) {\n emit('playTrack', 0); // Auto play if queue was empty\n }\n }\n}\n\nfunction extractMetadata(file) {\n return new Promise((resolve) => {\n if (!window.jsmediatags) {\n console.warn(\"jsmediatags not loaded\");\n resolve({ title: file.name, artist: 'Local File' });\n return;\n }\n\n window.jsmediatags.read(file, {\n onSuccess: (tag) => {\n const tags = tag.tags;\n\n let picture = null;\n if (tags.picture) {\n const { data, format } = tags.picture;\n let base64String = \"\";\n for (let i = 0; i < data.length; i++) {\n base64String += String.fromCharCode(data[i]);\n }\n picture = `data:${format};base64,${window.btoa(base64String)}`;\n }\n\n // Helper to extract tag value (handles both direct and .data formats)\n const getTagValue = (tagName) => {\n const tag = tags[tagName];\n if (!tag) return null;\n if (typeof tag === 'string' || typeof tag === 'number') return tag;\n if (tag.data !== undefined) return tag.data;\n return null;\n };\n\n // Dynamic search: find any tag containing \"bpm\" in name\n let bpm = null;\n let key = null;\n\n for (const tagName of Object.keys(tags)) {\n const lowerName = tagName.toLowerCase();\n const val = getTagValue(tagName);\n\n if (!bpm && (lowerName.includes('bpm') || lowerName.includes('beats'))) {\n bpm = val;\n }\n if (!key && (lowerName.includes('key') || lowerName === 'tkey')) {\n key = val;\n }\n }\n\n // Parse BPM as integer\n if (bpm) bpm = parseInt(String(bpm).replace(/\\D/g, ''), 10) || null;\n\n\n resolve({\n title: tags.title || file.name,\n artist: tags.artist || 'Local Artist',\n album: tags.album || 'Local Album',\n bpm: bpm,\n key: key,\n picture: picture\n });\n },\n onError: (error) => {\n console.warn('Metadata read error:', error);\n resolve({ title: file.name, artist: 'Local File' });\n }\n });\n });\n}\n\nfunction musicalKeyToCamelot(key) {\n if (!key) return null;\n\n // Normalize key: uppercase, handle sharps/flats\n const normalized = key.trim()\n .replace(/major/i, '')\n .replace(/minor/i, 'm')\n .replace(/♯/g, '#')\n .replace(/♭/g, 'b')\n .trim();\n\n // Mapping of musical keys to Camelot notation\n // Minor keys (A column)\n const minorKeys = {\n 'Abm': '1A', 'G#m': '1A',\n 'Ebm': '2A', 'D#m': '2A',\n 'Bbm': '3A', 'A#m': '3A',\n 'Fm': '4A',\n 'Cm': '5A',\n 'Gm': '6A',\n 'Dm': '7A',\n 'Am': '8A',\n 'Em': '9A',\n 'Bm': '10A',\n 'F#m': '11A', 'Gbm': '11A',\n 'Dbm': '12A', 'C#m': '12A'\n };\n\n // Major keys (B column)\n const majorKeys = {\n 'B': '1B',\n 'Gb': '2B', 'F#': '2B',\n 'Db': '3B', 'C#': '3B',\n 'Ab': '4B', 'G#': '4B',\n 'Eb': '5B', 'D#': '5B',\n 'Bb': '6B', 'A#': '6B',\n 'F': '7B',\n 'C': '8B',\n 'G': '9B',\n 'D': '10B',\n 'A': '11B',\n 'E': '12B'\n };\n\n // Check minor first, then major\n if (minorKeys[normalized]) return minorKeys[normalized];\n if (majorKeys[normalized]) return majorKeys[normalized];\n\n // Try case-insensitive match\n for (const [k, v] of Object.entries(minorKeys)) {\n if (k.toLowerCase() === normalized.toLowerCase()) return v;\n }\n for (const [k, v] of Object.entries(majorKeys)) {\n if (k.toLowerCase() === normalized.toLowerCase()) return v;\n }\n\n // If already in Camelot format, return as-is\n if (/^[1-9][0-2]?[AB]$/i.test(normalized)) {\n return normalized.toUpperCase();\n }\n\n return key; // Return original if no match\n}\n\nfunction addLocalTrackToQueue(file, metadata) {\n const blobUrl = URL.createObjectURL(file);\n const safeId = `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n\n // Convert musical key to Camelot notation for color coding\n const camelotKey = musicalKeyToCamelot(metadata.key);\n\n const track = {\n id: safeId,\n name: metadata.title,\n artists: metadata.artist,\n album: metadata.album,\n album_art: metadata.picture || '/static/icon.svg',\n duration: 'Unknown',\n isrc: safeId,\n audio_features: {\n bpm: metadata.bpm || 0,\n camelot: camelotKey || (metadata.bpm ? '?' : null),\n energy: 0.5,\n key: -1,\n mode: 1\n },\n src: blobUrl,\n is_local: true\n };\n\n state.queue.push(track);\n}\n\n// Initialize\n// Initialize\ndocument.addEventListener('DOMContentLoaded', () => {\n initLocalFiles();\n});\n\n// Expose for inline HTML handlers\nwindow.handleFiles = handleFiles;\nwindow.extractMetadata = extractMetadata;\n\n// CSS for drag highlight\nconst style = document.createElement('style');\nstyle.textContent = `\n body.dragging {\n border: 4px dashed #1db954;\n opacity: 0.8;\n }\n`;\ndocument.head.appendChild(style);\n\n// ========== AI RADIO ==========\nstate.aiRadioActive = false;\nstate.aiRadioFetching = false;\nstate.aiRadioSeedTrack = null; // Store original seed track to prevent genre drift\nconst aiRadioBtn = $('#ai-radio-btn');\nlet aiRadioStatusEl = null;\nlet aiRadioInterval = null;\n\nfunction initAIRadio() {\n // Toggle AI Radio\n if (aiRadioBtn) {\n aiRadioBtn.addEventListener('click', () => {\n state.aiRadioActive = !state.aiRadioActive;\n aiRadioBtn.classList.toggle('active', state.aiRadioActive);\n\n if (state.aiRadioActive) {\n // Store the original seed track when AI Radio starts\n const currentTrack = state.queue[Math.max(0, state.currentIndex)];\n state.aiRadioSeedTrack = currentTrack ? {\n name: currentTrack.name,\n artists: currentTrack.artists,\n bpm: currentTrack.audio_features?.bpm,\n camelot: currentTrack.audio_features?.camelot\n } : null;\n\n showAIRadioStatus('AI Radio Active');\n showToast('AI Radio started! Will auto-add similar tracks.');\n checkAndAddTracks(); // Start immediately\n\n // Set up periodic check every 2 minutes (120 seconds)\n aiRadioInterval = setInterval(() => {\n checkAndAddTracks();\n }, 120000);\n } else {\n hideAIRadioStatus();\n showToast('AI Radio stopped');\n state.aiRadioSeedTrack = null; // Clear seed track\n\n // Clear the interval\n if (aiRadioInterval) {\n clearInterval(aiRadioInterval);\n aiRadioInterval = null;\n }\n }\n });\n } else {\n console.error('AI Radio button not found! #ai-radio-btn');\n }\n\n // Mood selector is now rendered inside the Smart Playlist modal (initAIAssistant)\n\n // Hook into track end\n audioPlayer?.addEventListener('ended', () => {\n if (state.aiRadioActive) {\n setTimeout(checkAndAddTracks, 500);\n }\n });\n}\n\nfunction showAIRadioStatus(message) {\n if (!aiRadioStatusEl) {\n aiRadioStatusEl = document.createElement('div');\n aiRadioStatusEl.className = 'ai-radio-status';\n document.body.appendChild(aiRadioStatusEl);\n }\n aiRadioStatusEl.innerHTML = `\n \n ${message}\n `;\n aiRadioStatusEl.style.display = 'flex';\n}\n\nfunction hideAIRadioStatus() {\n if (aiRadioStatusEl) {\n aiRadioStatusEl.style.display = 'none';\n }\n}\n\n// ========== TASTE PROFILE COLLECTOR ==========\n// Samples tracks from library, playlists, and history to build a taste baseline\n// for Gemini to personalize Smart Playlists and AI Radio from day one.\nfunction collectTasteProfile() {\n const seen = new Set();\n const profile = [];\n\n function addTrack(name, artists) {\n if (!name || !artists) return;\n const key = `${artists} - ${name}`.toLowerCase();\n if (seen.has(key)) return;\n seen.add(key);\n profile.push(`${artists} - ${name}`);\n }\n\n // 1. Library (starred tracks) — up to 15\n if (state.library && state.library.length) {\n const libSample = state.library.slice(0, 15);\n libSample.forEach(t => addTrack(t.name, t.artists));\n }\n\n // 2. Playlists — up to 15 (round-robin across playlists)\n if (state.playlists && state.playlists.length) {\n let playlistCount = 0;\n const maxPerPlaylist = Math.max(2, Math.floor(15 / state.playlists.length));\n for (const pl of state.playlists) {\n if (playlistCount >= 15) break;\n const tracks = pl.tracks || [];\n for (let i = 0; i < Math.min(tracks.length, maxPerPlaylist); i++) {\n if (playlistCount >= 15) break;\n addTrack(tracks[i].name, tracks[i].artists);\n playlistCount++;\n }\n }\n }\n\n // 3. History (recently played) — up to 10\n if (state.history && state.history.length) {\n const histSample = state.history.slice(0, 10);\n histSample.forEach(t => addTrack(t.name, t.artists));\n }\n\n console.log(`[TasteProfile] library=${state.library?.length || 0}, playlists=${state.playlists?.length || 0}, history=${state.history?.length || 0} → ${profile.length} taste tracks`);\n return profile.length > 0 ? profile : undefined;\n}\n\nasync function checkAndAddTracks() {\n if (!state.aiRadioActive || state.aiRadioFetching) return;\n\n const remainingTracks = state.queue.length - Math.max(0, state.currentIndex) - 1;\n\n // Add more tracks if we have less than 3 remaining\n if (remainingTracks < 3) {\n state.aiRadioFetching = true;\n showAIRadioStatus('Finding similar tracks...');\n\n try {\n // Use the ORIGINAL seed track stored when AI Radio started (prevents genre drift)\n const seed = state.aiRadioSeedTrack;\n\n // Get current queue for exclusion\n const queueTracks = state.queue.map(t => ({\n name: t.name,\n artists: t.artists\n }));\n\n // Build mood context\n let moodLiked = [];\n let moodDisliked = [];\n if (state.currentMood) {\n const prefs = getMoodPreferences(state.currentMood);\n moodLiked = prefs.liked.slice(0, 5).map(t => `${t.name} - ${t.artist}`);\n moodDisliked = prefs.disliked.slice(0, 5).map(t => `${t.name} - ${t.artist}`);\n }\n\n const requestBody = {\n seed_track: seed,\n mood: state.currentMood || (seed ? null : \"popular music hits\"),\n current_queue: queueTracks,\n count: 5,\n mood_liked: moodLiked.length ? moodLiked : undefined,\n mood_disliked: moodDisliked.length ? moodDisliked : undefined,\n taste_profile: collectTasteProfile(),\n };\n\n\n async function processAIRadioResponse(response) {\n if (response.ok) {\n const data = await response.json();\n const searchTerms = data.search_terms || [];\n\n // Search and add tracks\n let addedCount = 0;\n for (const term of searchTerms) {\n if (addedCount >= 3) break; // Limit adds per batch\n\n try {\n const searchRes = await fetch(`/api/search?q=${encodeURIComponent(term)}&type=track`);\n if (searchRes.ok) {\n const searchData = await searchRes.json();\n const results = searchData.results || [];\n\n // Add first non-duplicate result\n for (const track of results.slice(0, 3)) {\n const isDupe = state.queue.some(q =>\n q.id === track.id ||\n (q.name?.toLowerCase() === track.name?.toLowerCase() &&\n q.artists?.toLowerCase() === track.artists?.toLowerCase())\n );\n if (!isDupe) {\n state.queue.push(track);\n addedCount++;\n break;\n }\n }\n }\n } catch (e) {\n console.warn('AI Radio search error:', e);\n }\n }\n\n if (addedCount > 0) {\n emit('updateQueueUI');\n showToast(`Added ${addedCount} tracks to queue`);\n }\n }\n }\n\n try {\n const response = await fetch('/api/ai-radio/generate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(requestBody)\n });\n await processAIRadioResponse(response);\n } catch (err) {\n console.error('AI Radio with mood failed, retrying without mood:', err);\n showToast('Mood-aware recommendations failed. Falling back to standard mode.');\n // Retry without mood context\n const fallbackResponse = await fetch('/api/ai-radio/generate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ seed_track: seed, current_queue: queueTracks, count: 5 })\n });\n await processAIRadioResponse(fallbackResponse);\n }\n } catch (err) {\n console.error('AI Radio error:', err);\n }\n\n state.aiRadioFetching = false;\n if (state.aiRadioActive) {\n showAIRadioStatus('AI Radio Active');\n }\n }\n}\n\n// Check when current track changes\non('trackStarted', () => {\n if (state.aiRadioActive) {\n setTimeout(checkAndAddTracks, 1000);\n }\n});\n\n// ========== ADD TO PLAYLIST MODAL ==========\nconst playlistModal = $('#playlist-modal');\nconst playlistList = $('#playlist-list');\nconst newPlaylistInput = $('#new-playlist-input');\nconst createPlaylistBtn = $('#create-playlist-btn');\nconst playlistModalClose = $('#playlist-modal-close');\nconst addToPlaylistBtn = $('#add-to-playlist-btn');\n\nlet pendingTrackForPlaylist = null;\n\nfunction openAddToPlaylistModal(track) {\n if (!track) {\n showToast('No track selected');\n return;\n }\n pendingTrackForPlaylist = track;\n\n // Render playlist list\n if (state.playlists.length === 0) {\n playlistList.innerHTML = '
No playlists yet. Create one below!
';\n } else {\n playlistList.innerHTML = state.playlists.map(p => `\n${escapeHtml(playlist.name)}
\n${escapeHtml(playlist.owner || 'Spotify')}
\nConnect Spotify or ListenBrainz to see personalized recommendations.
\nSet SPOTIFY_SP_DC or LISTENBRAINZ_TOKEN in your environment variables.
\n${escapeHtml(playlist.name)}
\n${escapeHtml(playlist.artists)}
\n \nNo playlists found. Weekly Exploration playlists are generated on Mondays!
\nCould not generate playlist. Try a different description.
';\n }\n } catch (e) {\n console.error('Playlist generation error:', e);\n aiPlaylistResults.innerHTML = 'Playlist generation failed. Please try again.
';\n } finally {\n aiPlaylistGenBtn.disabled = false;\n }\n });\n\n // Close on Escape\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape' && aiModal && !aiModal.classList.contains('hidden')) {\n closeAIModal();\n }\n });\n}\n\n// ========== SPOTIFY OAUTH & UI ==========\nfunction initSpotifyOAuth() {\n const spotifyBtn = document.getElementById('spotify-connect-btn');\n if (!spotifyBtn) return;\n\n // Check URL for OAuth return status\n const urlParams = new URLSearchParams(window.location.search);\n if (urlParams.get('spotify_connected') === 'true') {\n showToast('Connected to Spotify Account');\n // Clean URL\n window.history.replaceState({}, document.title, window.location.pathname);\n } else if (urlParams.get('spotify_error')) {\n showToast('Spotify connection failed: ' + urlParams.get('spotify_error'));\n window.history.replaceState({}, document.title, window.location.pathname);\n }\n\n // Check current status\n fetch('/api/spotify/status')\n .then(res => res.json())\n .then(data => {\n if (data.connected) {\n spotifyBtn.textContent = 'Disconnect Spotify';\n spotifyBtn.classList.add('spotify-connected');\n } else {\n spotifyBtn.textContent = 'Connect Spotify';\n }\n })\n .catch(console.error);\n\n // Handle click\n spotifyBtn.addEventListener('click', async () => {\n document.getElementById('search-more-menu')?.classList.add('hidden');\n\n if (spotifyBtn.classList.contains('spotify-connected')) {\n if (confirm('Disconnect from Spotify? This will re-enable 100-track limits on playlist imports.')) {\n try {\n await fetch('/api/spotify/disconnect', { method: 'POST' });\n spotifyBtn.textContent = 'Connect Spotify';\n spotifyBtn.classList.remove('spotify-connected');\n showToast('Disconnected from Spotify');\n } catch (e) {\n showToast('Error disconnecting');\n }\n }\n } else {\n // Redirect to login\n window.location.href = '/api/spotify/login';\n }\n });\n}\n\n// ========== CROSS-DEVICE SYNC UI ==========\n\nlet syncModalEl = null;\n\nexport function initSyncUI() {\n // Create sync modal (same pattern as Smart Playlist modal)\n syncModalEl = document.createElement('div');\n syncModalEl.id = 'sync-modal';\n syncModalEl.className = 'ai-modal hidden';\n syncModalEl.innerHTML = `\n \n \n `;\n document.body.appendChild(syncModalEl);\n\n // Open from search-more-menu button\n const syncMenuBtn = document.getElementById('sync-menu-btn');\n syncMenuBtn?.addEventListener('click', () => {\n syncModalEl.classList.remove('hidden');\n document.getElementById('search-more-menu')?.classList.add('hidden');\n });\n\n // Close handlers\n syncModalEl.querySelector('#sync-modal-close').addEventListener('click', () => {\n syncModalEl.classList.add('hidden');\n });\n syncModalEl.querySelector('.ai-modal-overlay').addEventListener('click', () => {\n syncModalEl.classList.add('hidden');\n });\n\n // Toggle handler\n const toggle = syncModalEl.querySelector('#sync-toggle');\n toggle.addEventListener('change', async () => {\n if (toggle.checked) {\n const devices = await discoverDevices();\n renderDeviceList(devices);\n if (devices.length > 0) {\n enableSync(`http://${devices[0].ip}:${devices[0].port}`);\n } else {\n const lastIp = localStorage.getItem('freedify_sync_last_ip');\n if (lastIp) {\n enableSync(lastIp);\n } else {\n showToast('No devices found. Enter IP manually.');\n }\n }\n } else {\n disableSync();\n }\n });\n\n // Manual connect handler\n syncModalEl.querySelector('#sync-manual-connect').addEventListener('click', () => {\n const ip = syncModalEl.querySelector('#sync-manual-ip').value.trim();\n if (!ip) return;\n const url = ip.startsWith('http') ? ip : `http://${ip}`;\n enableSync(url);\n syncModalEl.classList.add('hidden');\n });\n\n // Device role selector\n syncModalEl.querySelectorAll('.sync-role-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const role = btn.dataset.role;\n state.syncRole = role;\n localStorage.setItem('freedify_sync_role', JSON.stringify(role));\n\n // Update button active states\n syncModalEl.querySelectorAll('.sync-role-btn').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n\n // Update hint text\n const hint = syncModalEl.querySelector('#sync-role-hint');\n if (hint) {\n hint.textContent = role === 'remote'\n ? 'Audio plays on the other device. This device controls playback.'\n : 'Audio plays here. Other devices act as remotes.';\n }\n\n // Apply role immediately\n const player = document.getElementById('audio-player');\n const player2 = document.getElementById('audio-player-2');\n if (role === 'remote') {\n if (player) player.muted = true;\n if (player2) player2.muted = true;\n showToast('Remote mode — audio plays on the other device');\n } else {\n if (player) player.muted = state.muted;\n if (player2) player2.muted = state.muted;\n showToast('Speaker mode — audio plays on this device');\n }\n\n // Refresh the indicator label\n if (state.syncEnabled) updateSyncIndicator('connected');\n });\n });\n\n // Apply saved role on init (if remote, mute audio)\n if (state.syncRole === 'remote' && state.syncEnabled) {\n const player = document.getElementById('audio-player');\n const player2 = document.getElementById('audio-player-2');\n if (player) player.muted = true;\n if (player2) player2.muted = true;\n }\n\n // Close on Escape\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape' && syncModalEl && !syncModalEl.classList.contains('hidden')) {\n syncModalEl.classList.add('hidden');\n }\n });\n\n // Listen for status changes\n on('syncStatusChanged', (status) => {\n updateSyncIndicator(status);\n });\n}\n\nfunction renderDeviceList(devices) {\n const list = document.getElementById('sync-devices-list');\n if (!list) return;\n if (devices.length === 0) {\n list.innerHTML = 'No devices found. Try manual IP.
';\n return;\n }\n // escapeHtml on mDNS-provided values to prevent injection (CLAUDE.md rule)\n list.innerHTML = devices.map(d => `\n