{"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
\n ${escapeHtml(p.name)} (${p.tracks.length})\n
\n `).join('');\n\n // Click handler for each playlist item\n playlistList.querySelectorAll('.playlist-list-item').forEach(el => {\n el.addEventListener('click', () => {\n addToPlaylist(el.dataset.playlistId, pendingTrackForPlaylist);\n closeAddToPlaylistModal();\n });\n });\n }\n\n playlistModal.classList.remove('hidden');\n}\n\nfunction closeAddToPlaylistModal() {\n playlistModal.classList.add('hidden');\n pendingTrackForPlaylist = null;\n newPlaylistInput.value = '';\n}\n\n// Create new playlist from modal\ncreatePlaylistBtn?.addEventListener('click', () => {\n const name = newPlaylistInput.value.trim();\n if (!name) {\n showToast('Enter a playlist name');\n return;\n }\n\n let tracks = [];\n if (pendingTrackForPlaylist) {\n tracks = Array.isArray(pendingTrackForPlaylist) ? pendingTrackForPlaylist : [pendingTrackForPlaylist];\n }\n\n const newPlaylist = createPlaylist(name, tracks);\n closeAddToPlaylistModal();\n});\n\n// Close modal\nplaylistModalClose?.addEventListener('click', closeAddToPlaylistModal);\nplaylistModal?.addEventListener('click', (e) => {\n if (e.target === playlistModal) closeAddToPlaylistModal();\n});\n\n// Heart button in More Menu -> opens modal for current track\naddToPlaylistBtn?.addEventListener('click', () => {\n const currentTrack = state.queue[state.currentIndex];\n if (currentTrack) {\n openAddToPlaylistModal(currentTrack);\n // Close More menu\n $('#player-more-menu')?.classList.add('hidden');\n } else {\n showToast('No track playing');\n }\n});\n\n// Expose for queue item hearts (will be wired in updateQueueUI)\nwindow.openAddToPlaylistModal = openAddToPlaylistModal;\n\n// ========== LOAD MORE RESULTS ==========\nconst loadMoreBtn = $('#load-more-btn');\nif (loadMoreBtn) {\n loadMoreBtn.addEventListener('click', () => {\n if (state.lastSearchQuery) {\n emit('performSearch', { query: state.lastSearchQuery, append: true });\n }\n });\n}\n\n// ========== LISTENBRAINZ LOGIC ==========\n// Scrobble Logic\nasync function submitNowPlaying(track) {\n // ListenBrainz\n if (state.listenBrainzConfig.valid) {\n try {\n await fetch('/api/listenbrainz/now-playing', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify(track)\n });\n } catch (e) { console.error('LB Now playing error:', e); }\n }\n // Last.fm\n const lfmSession = localStorage.getItem('lastfm_session_key');\n if (lfmSession) {\n try {\n await fetch('/api/lastfm/nowplaying', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n session_key: lfmSession,\n artist: track.artists || '',\n track: track.name || '',\n album: track.album || ''\n })\n });\n } catch (e) { console.error('Last.fm now playing error:', e); }\n }\n}\n\nasync function submitScrobble(track) {\n const lbValid = state.listenBrainzConfig.valid;\n const lfmSession = localStorage.getItem('lastfm_session_key');\n\n if ((!lbValid && !lfmSession) || state.scrobbledCurrent) return;\n\n state.scrobbledCurrent = true; // Prevent double scrobble\n\n // ListenBrainz scrobble\n if (lbValid) {\n try {\n await fetch('/api/listenbrainz/scrobble', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify(track)\n });\n } catch (e) { console.error('LB Scrobble error:', e); }\n }\n\n // Last.fm scrobble\n if (lfmSession) {\n try {\n await fetch('/api/lastfm/scrobble', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n session_key: lfmSession,\n artist: track.artists || '',\n track: track.name || '',\n album: track.album || '',\n timestamp: Math.floor(Date.now() / 1000)\n })\n });\n } catch (e) { console.error('Last.fm Scrobble error:', e); }\n }\n}\n\n// Check initial LB status\nfetch('/api/listenbrainz/validate')\n .then(res => res.json())\n .then(data => {\n state.listenBrainzConfig = data;\n })\n .catch(console.error);\n\n// ========== LAST.FM AUTH & UI ==========\n(function initLastFM() {\n const lfmUsername = localStorage.getItem('lastfm_username');\n const lfmSessionKey = localStorage.getItem('lastfm_session_key');\n\n // Use the Last.fm button in the More menu\n const lfmBtn = document.getElementById('lastfm-menu-btn');\n if (lfmBtn) {\n // Update button text based on connection state\n if (lfmSessionKey) {\n lfmBtn.textContent = `Last.fm: ${lfmUsername || 'Connected'}`;\n lfmBtn.classList.add('lastfm-connected');\n } else {\n lfmBtn.textContent = 'Connect Last.fm';\n }\n\n lfmBtn.addEventListener('click', () => {\n // Close both the More menu and settings modal\n document.getElementById('search-more-menu')?.classList.add('hidden');\n document.getElementById('settings-modal')?.classList.add('hidden');\n\n if (localStorage.getItem('lastfm_session_key')) {\n // Already connected — show disconnect option\n if (confirm(`Connected as ${localStorage.getItem('lastfm_username') || 'Unknown'}.\\nDisconnect from Last.fm?`)) {\n localStorage.removeItem('lastfm_session_key');\n localStorage.removeItem('lastfm_username');\n lfmBtn.textContent = 'Connect Last.fm';\n lfmBtn.classList.remove('lastfm-connected');\n showToast('Disconnected from Last.fm');\n }\n } else {\n // Start auth flow\n connectLastFM();\n }\n });\n }\n\n // Hook up Import Playlist button\n const importBtn = document.getElementById('import-playlist-menu-btn');\n if (importBtn) {\n importBtn.addEventListener('click', () => {\n document.getElementById('search-more-menu')?.classList.add('hidden');\n document.getElementById('playlist-file-input')?.click();\n });\n }\n\n // Guard: only exchange a token once (tokens are single-use on Last.fm)\n let _lastfmExchangedToken = null;\n\n function safeExchangeLastFMToken(token) {\n if (!token || token === _lastfmExchangedToken) return;\n _lastfmExchangedToken = token;\n exchangeLastFMToken(token);\n }\n\n // Check for pending token (fallback from redirect)\n const pendingToken = localStorage.getItem('lastfm_pending_token');\n if (pendingToken) {\n localStorage.removeItem('lastfm_pending_token');\n safeExchangeLastFMToken(pendingToken);\n }\n\n // Listen for postMessage from auth popup\n window.addEventListener('message', (event) => {\n if (event.data?.type === 'lastfm-auth' && event.data.token) {\n safeExchangeLastFMToken(event.data.token);\n }\n });\n\n // Listen for BroadcastChannel (fallback when window.opener is null)\n try {\n const bc = new BroadcastChannel('freedify_lastfm');\n bc.onmessage = (event) => {\n if (event.data?.type === 'lastfm-auth' && event.data.token) {\n safeExchangeLastFMToken(event.data.token);\n }\n };\n } catch(e) {}\n\n // Check for pending token when window regains focus (fallback if both channels fail)\n window.addEventListener('focus', () => {\n const pt = localStorage.getItem('lastfm_pending_token');\n if (pt) {\n localStorage.removeItem('lastfm_pending_token');\n safeExchangeLastFMToken(pt);\n }\n });\n\n if (lfmSessionKey) {\n }\n})();\n\nasync function connectLastFM() {\n const callbackUrl = `${window.location.origin}/lastfm-callback`;\n try {\n const res = await fetch(`/api/lastfm/auth-url?callback=${encodeURIComponent(callbackUrl)}`);\n const data = await res.json();\n if (data.url) {\n // Open auth in popup\n const popup = window.open(data.url, 'lastfm_auth', 'width=800,height=600,scrollbars=yes');\n if (!popup) {\n // Popup blocked — redirect instead\n window.location.href = data.url;\n }\n }\n } catch (e) {\n console.error('Last.fm auth error:', e);\n showToast('Failed to connect Last.fm');\n }\n}\n\nasync function exchangeLastFMToken(token) {\n try {\n const res = await fetch('/api/lastfm/callback', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({token})\n });\n\n if (res.ok) {\n const data = await res.json();\n localStorage.setItem('lastfm_session_key', data.session_key);\n localStorage.setItem('lastfm_username', data.username);\n\n // Update button\n const lfmBtn = document.getElementById('lastfm-menu-btn');\n if (lfmBtn) {\n lfmBtn.textContent = `Last.fm: ${data.username}`;\n lfmBtn.classList.add('lastfm-connected');\n }\n\n showToast(`Connected to Last.fm as ${data.username}`);\n } else {\n showToast('Last.fm authorization failed');\n }\n } catch (e) {\n console.error('Last.fm token exchange error:', e);\n showToast('Last.fm connection error');\n }\n}\n\n// ========== RECOMMENDATIONS VIEW ==========\n\nasync function renderRecommendations() {\n resultsSection.classList.remove('hidden');\n detailView.classList.add('hidden');\n queueSection.classList.add('hidden');\n\n let html = '';\n\n // ========== SPOTIFY \"MADE FOR YOU\" SECTION ==========\n // DISABLED: Spotify's sp_dc cookie auth doesn't provide personalized search results.\n // Re-enable when Spotify Developer API access is available with proper OAuth scopes.\n /*\n try {\n const spotifyRes = await fetch('/api/spotify/made-for-you');\n if (spotifyRes.ok) {\n const spotifyPlaylists = await spotifyRes.json();\n\n if (spotifyPlaylists && spotifyPlaylists.length > 0) {\n html += `\n
\n

Spotify For You

\n ${spotifyPlaylists.length} playlists\n
\n
\n `;\n\n for (const playlist of spotifyPlaylists) {\n html += `\n
\n
\n \"${escapeHtml(playlist.name)}\"\n Spotify\n
\n
\n

${escapeHtml(playlist.name)}

\n

${escapeHtml(playlist.owner || 'Spotify')}

\n
\n
\n `;\n }\n\n html += '
';\n }\n }\n } catch (e) {\n console.warn('Could not load Spotify Made For You:', e);\n }\n */\n\n // ========== LISTENBRAINZ SECTION ==========\n if (!state.listenBrainzConfig.valid) {\n if (!html) {\n // No Spotify AND no ListenBrainz - show empty state\n resultsContainer.innerHTML = `\n
\n
✨
\n

Connect Spotify or ListenBrainz to see personalized recommendations.

\n

Set SPOTIFY_SP_DC or LISTENBRAINZ_TOKEN in your environment variables.

\n
\n `;\n } else {\n resultsContainer.innerHTML = html;\n attachSpotifyMFYHandlers();\n }\n return;\n }\n\n showLoading('Loading your ListenBrainz playlists...');\n try {\n // Fetch playlists first\n const playlistsRes = await fetch(`/api/listenbrainz/playlists/${state.listenBrainzConfig.username}`);\n const playlistsData = await playlistsRes.json();\n\n hideLoading();\n\n // Fetch and display stats panel\n try {\n const statsRes = await fetch(`/api/listenbrainz/stats/${state.listenBrainzConfig.username}`);\n if (statsRes.ok) {\n const stats = await statsRes.json();\n if (stats.listen_count > 0) {\n html += `\n
\n
\n ${stats.listen_count.toLocaleString()}\n Total Scrobbles\n
\n ${stats.top_artists.length > 0 ? `\n
\n Top This Week\n
\n ${stats.top_artists.slice(0, 3).map(a => `${escapeHtml(a.name)}`).join('')}\n
\n
\n ` : ''}\n
\n `;\n }\n }\n } catch (e) {\n console.warn('Could not load LB stats:', e);\n }\n\n // Show playlists section\n if (playlistsData.playlists && playlistsData.playlists.length > 0) {\n html += `\n
\n

ListenBrainz Playlists

\n ${playlistsData.count} playlists\n
\n
\n `;\n\n for (const playlist of playlistsData.playlists) {\n html += `\n
\n
\n \"${escapeHtml(playlist.name)}\"\n LB\n
\n
\n

${escapeHtml(playlist.name)}

\n

${escapeHtml(playlist.artists)}

\n
\n ${playlist.total_tracks || '?'} tracks\n
\n
\n
\n `;\n }\n\n html += '
';\n } else {\n html += `\n
\n

ListenBrainz Playlists

\n
\n
\n
📋
\n

No playlists found. Weekly Exploration playlists are generated on Mondays!

\n
\n `;\n }\n\n resultsContainer.innerHTML = html;\n\n // Attach click handlers for playlist cards\n resultsContainer.querySelectorAll('.lb-playlist-card').forEach(card => {\n card.addEventListener('click', async () => {\n const playlistId = card.dataset.id;\n await openLBPlaylist(playlistId);\n });\n });\n\n attachSpotifyMFYHandlers();\n\n } catch (e) {\n console.error(e);\n showError('Failed to load ListenBrainz data');\n }\n}\n\n// Attach click handlers for Spotify \"Made For You\" cards\nfunction attachSpotifyMFYHandlers() {\n resultsContainer.querySelectorAll('.spotify-mfy-card').forEach(card => {\n card.addEventListener('click', async () => {\n const playlistId = card.dataset.id;\n await openSpotifyPlaylist(playlistId);\n });\n });\n}\n\n// Open a Spotify playlist in detail view\nasync function openSpotifyPlaylist(playlistId) {\n showLoading('Loading playlist tracks...');\n try {\n const res = await fetch(`/api/content/playlist/${playlistId}?source=spotify`);\n const playlist = await res.json();\n\n if (!res.ok) throw new Error(playlist.detail);\n\n hideLoading();\n\n // Show in detail view\n emit('showDetailView', { item: playlist, tracks: playlist.tracks || [] });\n } catch (e) {\n console.error('Failed to load Spotify playlist:', e);\n showError('Failed to load playlist');\n }\n}\n\n// Open a ListenBrainz playlist in detail view\nasync function openLBPlaylist(playlistId) {\n showLoading('Loading playlist tracks...');\n try {\n const res = await fetch(`/api/listenbrainz/playlist/${playlistId}`);\n const playlist = await res.json();\n\n if (!res.ok) throw new Error(playlist.detail);\n\n hideLoading();\n\n // Show in detail view\n emit('showDetailView', { item: playlist, tracks: playlist.tracks || [] });\n } catch (e) {\n console.error('Failed to load LB playlist:', e);\n showError('Failed to load playlist');\n }\n}\n\n// ==================== WINAMP MINI PLAYER ====================\nasync function toggleMiniPlayer() {\n if (!('documentPictureInPicture' in window)) {\n showError('Mini Player not supported in this browser (Chrome/Edge 116+ required)');\n return;\n }\n\n if (domState.pipWindow) {\n domState.pipWindow.close();\n domState.pipWindow = null;\n return;\n }\n\n try {\n domState.pipWindow = await documentPictureInPicture.requestWindow({\n width: 320,\n height: 160,\n });\n\n // Copy Styles\n [...document.styleSheets].forEach((styleSheet) => {\n try {\n const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');\n const style = document.createElement('style');\n style.textContent = cssRules;\n domState.pipWindow.document.head.appendChild(style);\n } catch (e) {\n // Ignore CORS errors for external sheets\n const link = document.createElement('link');\n link.rel = 'stylesheet';\n link.type = styleSheet.type;\n link.media = styleSheet.media;\n link.href = styleSheet.href;\n domState.pipWindow.document.head.appendChild(link);\n }\n });\n\n // Render Winamp HTML\n updateMiniPlayerDOM();\n\n // Bind Controls\n const doc = domState.pipWindow.document;\n doc.getElementById('wa-prev').onclick = () => emit('playPrevious');\n doc.getElementById('wa-play').onclick = () => {\n const p = getActivePlayer();\n if (p.paused) p.play(); else p.pause();\n updateMiniPlayer(); // Immediate update\n };\n doc.getElementById('wa-pause').onclick = () => getActivePlayer().pause();\n doc.getElementById('wa-next').onclick = () => emit('playNext');\n doc.getElementById('wa-vol').oninput = (e) => {\n const val = e.target.value / 100;\n emit('updateVolume', val); // Syncs main slider too\n };\n\n // Force initial update\n updateMiniPlayer();\n\n // Handle Close\n domState.pipWindow.addEventListener('pagehide', () => {\n domState.pipWindow = null;\n });\n\n } catch (err) {\n console.error('Failed to open Mini Player:', err);\n }\n}\n\nfunction updateMiniPlayerDOM() {\n if (!domState.pipWindow) return;\n const doc = domState.pipWindow.document;\n\n // If body is empty, inject structure\n if (!doc.body.children.length) {\n doc.body.className = 'winamp-body';\n doc.body.innerHTML = `\n
\n
\n
\n FREEDIFY\n \n
\n
\n
\n
\n \n
\n
\n
\n
00:00
\n
Ready to Llama...
\n
\n MP3\n STOP\n
\n
\n
\n
\n
\n
\n
\n
VOL
\n \n
\n
\n
\n
\n `;\n }\n}\n\nfunction updateMiniPlayer() {\n if (!domState.pipWindow) return;\n const doc = domState.pipWindow.document;\n const player = getActivePlayer();\n\n // Time\n const cur = player.currentTime || 0;\n const mins = Math.floor(cur / 60);\n const secs = Math.floor(cur % 60);\n const timeStr = `${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;\n const timeEl = doc.getElementById('wa-time');\n if (timeEl && timeEl.textContent !== timeStr) timeEl.textContent = timeStr;\n\n // Metadata (Title - Artist)\n const title = $('#player-title').textContent;\n const artist = $('#player-artist').textContent;\n const text = `${artist} - ${title}`;\n const marquee = doc.getElementById('wa-marquee');\n if (marquee) {\n // Update span content or create one if missing\n let span = marquee.querySelector('span');\n if (!span) {\n span = doc.createElement('span');\n marquee.appendChild(span);\n }\n if (span.textContent !== text) span.textContent = text;\n }\n\n // State (Play/Pause/Stop)\n const stateEl = doc.getElementById('wa-state');\n const newState = player.paused ? 'PAUSE' : 'PLAY';\n if (stateEl && stateEl.textContent !== newState) stateEl.textContent = newState;\n\n // Art\n const mainArt = $('#player-art');\n const waArt = doc.getElementById('wa-art');\n if (waArt && mainArt && waArt.src !== mainArt.src) waArt.src = mainArt.src;\n\n // Format\n const badge = $('#audio-format-badge');\n const waFormat = doc.getElementById('wa-format');\n if (waFormat && badge) {\n waFormat.textContent = badge.textContent || 'MP3';\n waFormat.style.color = (badge.textContent === 'HiFi' || badge.textContent === 'FLAC') ? '#00e000' : '#c0c000';\n }\n}\n\n// ==================== AI ASSISTANT MODAL ====================\n\nconst aiModal = document.getElementById('ai-modal');\nconst aiModalClose = document.getElementById('ai-modal-close');\nconst aiModalOverlay = aiModal?.querySelector('.ai-modal-overlay');\nconst aiMenuBtn = document.getElementById('ai-menu-btn');\n\n// Playlist Generator elements\nconst aiPlaylistInput = document.getElementById('ai-playlist-input');\nconst aiPlaylistGenBtn = document.getElementById('ai-playlist-gen-btn');\nconst aiPlaylistResults = document.getElementById('ai-playlist-results');\nconst aiDurationSelect = document.getElementById('ai-duration-select');\n\n// Open/Close Modal\nfunction openAIModal() {\n if (aiModal) {\n aiModal.classList.remove('hidden');\n // Refresh mood selector with latest stats each time modal opens\n const moodContainer = document.getElementById('mood-selector-container');\n if (moodContainer) renderMoodSelector(moodContainer);\n aiPlaylistInput?.focus();\n // Hide menu if open\n document.getElementById('search-more-menu')?.classList.add('hidden');\n }\n}\n\nfunction closeAIModal() {\n if (aiModal) {\n aiModal.classList.add('hidden');\n }\n}\n\nfunction initAIAssistant() {\n // Event listeners\n aiMenuBtn?.addEventListener('click', openAIModal);\n aiModalClose?.addEventListener('click', closeAIModal);\n aiModalOverlay?.addEventListener('click', closeAIModal);\n\n // Render mood selector inside the Smart Playlist modal\n const moodContainer = document.getElementById('mood-selector-container');\n if (moodContainer) {\n renderMoodSelector(moodContainer);\n }\n\n // Playlist Generator\n aiPlaylistGenBtn?.addEventListener('click', async () => {\n const description = aiPlaylistInput?.value?.trim();\n if (!description) return;\n\n const duration = parseInt(aiDurationSelect?.value) || 60;\n\n aiPlaylistGenBtn.disabled = true;\n aiPlaylistResults.innerHTML = '
Generating playlist
';\n\n try {\n const res = await fetch('/api/ai/generate-playlist', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n description,\n duration_mins: duration,\n mood: state.currentMood || undefined,\n mood_liked: (() => {\n if (!state.currentMood) return undefined;\n const prefs = getMoodPreferences(state.currentMood);\n const liked = prefs.liked.slice(0, 5).map(t => `${t.name} - ${t.artist}`);\n return liked.length ? liked : undefined;\n })(),\n mood_disliked: (() => {\n if (!state.currentMood) return undefined;\n const prefs = getMoodPreferences(state.currentMood);\n const disliked = prefs.disliked.slice(0, 5).map(t => `${t.name} - ${t.artist}`);\n return disliked.length ? disliked : undefined;\n })(),\n taste_profile: collectTasteProfile(),\n })\n });\n const data = await res.json();\n\n if (data.tracks && data.tracks.length > 0) {\n let html = `\n
\n ${data.playlist_name || 'Generated Playlist'}\n ${data.tracks.length} tracks\n
\n `;\n\n data.tracks.forEach((track, i) => {\n html += `\n
\n ${i + 1}\n
\n
${escapeHtml(track.title)}
\n
${escapeHtml(track.artist)}
\n
\n
\n `;\n });\n\n html += '';\n\n aiPlaylistResults.innerHTML = html;\n\n // Click handler for individual tracks\n aiPlaylistResults.querySelectorAll('.ai-track-item').forEach(item => {\n item.addEventListener('click', async () => {\n const searchQuery = `${item.dataset.artist} ${item.dataset.title}`;\n closeAIModal();\n searchInput.value = searchQuery;\n emit('performSearch', searchQuery);\n });\n });\n\n // Add all button\n document.getElementById('ai-add-all')?.addEventListener('click', async () => {\n closeAIModal();\n const wasEmpty = state.queue.length === 0;\n // Auto-play if queue was empty OR nothing is currently loaded/playing\n const shouldAutoPlay = wasEmpty || !state.currentTrack;\n\n const tracks = data.tracks;\n if (tracks.length === 0) return;\n\n // Helper to search for a track\n const searchTrack = async (track) => {\n try {\n const searchRes = await fetch(`/api/search?q=${encodeURIComponent(track.artist + ' ' + track.title)}&type=track&limit=1`);\n const searchData = await searchRes.json();\n if (searchData.results && searchData.results.length > 0) {\n return searchData.results[0];\n }\n } catch (e) {\n console.error('Failed to find track:', track.title);\n }\n return null;\n };\n\n let hasStartedPlaying = false;\n\n // 1. Process FIRST track immediately for instant playback\n const firstTrack = tracks[0];\n const firstResult = await searchTrack(firstTrack);\n\n if (firstResult) {\n emit('addToQueue', firstResult);\n if (shouldAutoPlay) {\n emit('playTrack', firstResult);\n hasStartedPlaying = true;\n }\n }\n\n // 2. Process REST in parallel to preserve order but run fast\n if (tracks.length > 1) {\n const restTracks = tracks.slice(1);\n // Fetch all in parallel\n // Note: If list is huge (e.g. 50+), we might want to batch this.\n // But for typical AI playlists (10-20), Promise.all is fine.\n const results = await Promise.all(restTracks.map(t => searchTrack(t)));\n\n // Add valid results to queue in order\n let addedCount = 0;\n results.forEach(result => {\n if (result) {\n emit('addToQueue', result);\n addedCount++;\n\n // Fallback: If first track failed to play/find, play the first valid one we found here\n if (shouldAutoPlay && !hasStartedPlaying) {\n emit('playTrack', result);\n hasStartedPlaying = true;\n }\n }\n });\n\n if (addedCount > 0) {\n showToast(`Added ${addedCount + (firstResult ? 1 : 0)} tracks to queue`);\n }\n }\n });\n } else {\n aiPlaylistResults.innerHTML = '

Could 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

🔗 Sync Devices

\n \n
\n
\n
\n Enable Sync\n \n
\n
\n \n
\n \n \n
\n

${state.syncRole === 'remote' ? 'Audio plays on the other device. This device controls playback.' : 'Audio plays here. Other devices act as remotes.'}

\n
\n
\n

Toggle on to discover devices on your network

\n
\n
\n \n \n
\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
\n ${escapeHtml(d.name)}\n ${escapeHtml(d.ip)}:${escapeHtml(String(d.port))}\n
\n `).join('');\n}\n\nfunction updateSyncIndicator(status) {\n const indicator = document.getElementById('sync-indicator');\n if (!indicator) return;\n\n if (status === 'connected') {\n const roleLabel = state.syncRole === 'remote' ? 'Remote' : 'Playing';\n indicator.textContent = `🔗 ${roleLabel}`;\n indicator.classList.remove('hidden');\n indicator.title = `Synced — ${roleLabel}`;\n } else if (status === 'connecting' || (status === 'disconnected' && state.syncEnabled)) {\n // Keep visible during reconnect to avoid layout jitter\n indicator.textContent = '🔗 …';\n indicator.classList.remove('hidden');\n indicator.title = 'Reconnecting...';\n } else {\n indicator.classList.add('hidden');\n }\n}\n\n// ========== FULL DATA EXPORT / IMPORT ==========\n\nfunction exportAllData() {\n try {\n const backup = {\n _meta: {\n app: 'Freedify',\n version: 1,\n exportedAt: new Date().toISOString()\n },\n // Core data (mirrors Drive sync)\n playlists: state.playlists,\n library: state.library,\n history: state.history,\n queue: state.queue,\n currentIndex: state.currentIndex,\n volume: state.volume,\n // Podcast & audiobook data\n podcastFavorites: state.podcastFavorites,\n audiobookFavorites: state.audiobookFavorites,\n podcastPlayedEpisodes: state.podcastPlayedEpisodes,\n podcastResumePositions: state.podcastResumePositions,\n podcastHistory: state.podcastHistory,\n audiobookHistory: state.audiobookHistory,\n podcastTags: state.podcastTags,\n // Mood data\n moodHistory: state.moodHistory,\n moodPreferences: state.moodPreferences,\n // Watched playlists\n watchedPlaylists: state.watchedPlaylists,\n // Settings\n settings: {\n theme: localStorage.getItem('freedify_theme') || '',\n hiRes: localStorage.getItem('freedify_hires') || 'false',\n hiResQuality: localStorage.getItem('freedify_hires_quality') || '7',\n crossfade: localStorage.getItem('freedify_crossfade') || 'false',\n lastfmUsername: localStorage.getItem('freedify_lastfm_user') || '',\n eqSettings: localStorage.getItem('freedify_eq') || '',\n googleClientId: localStorage.getItem('freedify_google_client_id') || ''\n }\n };\n\n const json = JSON.stringify(backup, null, 2);\n const blob = new Blob([json], { type: 'application/json;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n\n const date = new Date().toISOString().slice(0, 10);\n const a = document.createElement('a');\n a.href = url;\n a.download = `freedify_backup_${date}.json`;\n document.body.appendChild(a);\n a.click();\n setTimeout(() => {\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }, 100);\n\n const counts = [\n `${state.playlists.length} playlists`,\n `${state.library.length} library tracks`,\n `${state.history.length} history items`\n ].join(', ');\n showToast(`Exported: ${counts}`);\n } catch (e) {\n console.error('Export error:', e);\n showToast('Failed to export data');\n }\n}\n\nasync function importAllData(file) {\n try {\n const text = await file.text();\n const data = JSON.parse(text);\n\n // Basic validation\n if (!data || typeof data !== 'object') {\n showToast('Invalid backup file');\n return;\n }\n\n // Check for at least one expected key\n const expectedKeys = ['playlists', 'library', 'history', 'podcastFavorites', 'audiobookFavorites'];\n const hasAny = expectedKeys.some(k => data[k] !== undefined);\n if (!hasAny) {\n showToast('This file doesn\\'t look like a Freedify backup');\n return;\n }\n\n if (!confirm(`Import Freedify backup from ${data._meta?.exportedAt || 'unknown date'}?\\n\\nThis will REPLACE all your current data.`)) {\n return;\n }\n\n showLoading('Importing data...');\n\n // Restore core data\n if (data.playlists) {\n state.playlists = data.playlists;\n savePlaylists();\n }\n if (data.library) {\n state.library = data.library;\n saveLibrary();\n }\n if (data.history) {\n state.history = data.history;\n saveHistory();\n }\n if (data.queue) {\n state.queue = data.queue;\n localStorage.setItem('freedify_queue', JSON.stringify(data.queue));\n }\n if (data.currentIndex !== undefined) {\n state.currentIndex = data.currentIndex;\n }\n if (data.volume !== undefined) {\n state.volume = data.volume;\n }\n\n // Podcast & audiobook data\n if (data.podcastFavorites) {\n state.podcastFavorites = data.podcastFavorites;\n savePodcastFavorites();\n }\n if (data.audiobookFavorites) {\n state.audiobookFavorites = data.audiobookFavorites;\n saveAudiobookFavorites();\n }\n if (data.podcastPlayedEpisodes) {\n state.podcastPlayedEpisodes = data.podcastPlayedEpisodes;\n savePodcastPlayed();\n }\n if (data.podcastResumePositions) {\n state.podcastResumePositions = data.podcastResumePositions;\n savePodcastResumePositions();\n }\n if (data.podcastHistory) {\n state.podcastHistory = data.podcastHistory;\n savePodcastHistory();\n }\n if (data.audiobookHistory) {\n state.audiobookHistory = data.audiobookHistory;\n saveAudiobookHistory();\n }\n if (data.podcastTags) {\n state.podcastTags = data.podcastTags;\n savePodcastTags();\n }\n\n // Mood data\n if (data.moodHistory) {\n state.moodHistory = data.moodHistory;\n localStorage.setItem('freedify_mood_history', JSON.stringify(data.moodHistory));\n }\n if (data.moodPreferences) {\n state.moodPreferences = data.moodPreferences;\n localStorage.setItem('freedify_mood_preferences', JSON.stringify(data.moodPreferences));\n }\n\n // Watched playlists\n if (data.watchedPlaylists) {\n state.watchedPlaylists = data.watchedPlaylists;\n localStorage.setItem('freedify_watched', JSON.stringify(data.watchedPlaylists));\n }\n\n // Settings\n if (data.settings) {\n const s = data.settings;\n if (s.theme) localStorage.setItem('freedify_theme', s.theme);\n if (s.hiRes) localStorage.setItem('freedify_hires', s.hiRes);\n if (s.hiResQuality) localStorage.setItem('freedify_hires_quality', s.hiResQuality);\n if (s.crossfade) localStorage.setItem('freedify_crossfade', s.crossfade);\n if (s.lastfmUsername) localStorage.setItem('freedify_lastfm_user', s.lastfmUsername);\n if (s.eqSettings) localStorage.setItem('freedify_eq', s.eqSettings);\n if (s.googleClientId) localStorage.setItem('freedify_google_client_id', s.googleClientId);\n }\n\n hideLoading();\n showToast('Data imported successfully! Reloading...');\n setTimeout(() => location.reload(), 1500);\n } catch (e) {\n hideLoading();\n console.error('Import error:', e);\n showToast('Failed to import — invalid or corrupted file');\n }\n}\n\nfunction initDataExportImport() {\n const exportBtn = $('#export-all-data-btn');\n const importBtn = $('#import-all-data-btn');\n const importInput = $('#import-all-data-input');\n\n exportBtn?.addEventListener('click', () => {\n exportAllData();\n // Close the More menu\n $('#search-more-menu')?.classList.add('hidden');\n });\n\n importBtn?.addEventListener('click', () => {\n importInput?.click();\n $('#search-more-menu')?.classList.add('hidden');\n });\n\n importInput?.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) importAllData(file);\n e.target.value = ''; // Reset\n });\n}\n\n// ========== EXPORTS ==========\nexport {\n updateMediaSession,\n submitNowPlaying,\n submitScrobble,\n updateMiniPlayer,\n toggleMiniPlayer,\n openAddToPlaylistModal,\n renderRecommendations,\n initLocalFiles,\n initGoogleDriveSync,\n initSpotifyOAuth,\n initAIRadio,\n initAIAssistant,\n checkAndAddTracks,\n initDataExportImport\n};\n"}}}}