Files

1 line
95 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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 <span class=\"spinner-small\"></span>\n <span>${message}</span>\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 = '<p style=\"color: var(--text-tertiary); text-align:center; padding:16px;\">No playlists yet. Create one below!</p>';\n } else {\n playlistList.innerHTML = state.playlists.map(p => `\n <div class=\"playlist-list-item\" data-playlist-id=\"${p.id}\">\n ${escapeHtml(p.name)} <span style=\"opacity:0.6\">(${p.tracks.length})</span>\n </div>\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 <div class=\"results-header\">\n <h2>Spotify For You</h2>\n <span class=\"results-count\">${spotifyPlaylists.length} playlists</span>\n </div>\n <div class=\"results-grid horizontal-scroll\" id=\"spotify-mfy-grid\">\n `;\n\n for (const playlist of spotifyPlaylists) {\n html += `\n <div class=\"album-card spotify-mfy-card\" data-id=\"${playlist.id}\">\n <div class=\"album-card-art-container\">\n <img class=\"album-card-art\" src=\"${playlist.image || '/static/icon.png'}\" alt=\"${escapeHtml(playlist.name)}\" loading=\"lazy\">\n <span class=\"hires-badge\" style=\"background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);\">Spotify</span>\n </div>\n <div class=\"album-card-info\">\n <p class=\"album-card-title\">${escapeHtml(playlist.name)}</p>\n <p class=\"album-card-artist\">${escapeHtml(playlist.owner || 'Spotify')}</p>\n </div>\n </div>\n `;\n }\n\n html += '</div>';\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 <div class=\"empty-state\">\n <div class=\"empty-icon\">✨</div>\n <p class=\"empty-text\">Connect Spotify or ListenBrainz to see personalized recommendations.</p>\n <p class=\"empty-text\" style=\"font-size: 0.9em; opacity: 0.8; margin-top: 8px;\">Set SPOTIFY_SP_DC or LISTENBRAINZ_TOKEN in your environment variables.</p>\n </div>\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 <div class=\"stats-panel\">\n <div class=\"stats-item\">\n <span class=\"stats-value\">${stats.listen_count.toLocaleString()}</span>\n <span class=\"stats-label\">Total Scrobbles</span>\n </div>\n ${stats.top_artists.length > 0 ? `\n <div class=\"stats-item top-artists\">\n <span class=\"stats-label\">Top This Week</span>\n <div class=\"stats-artists\">\n ${stats.top_artists.slice(0, 3).map(a => `<span class=\"artist-tag\">${escapeHtml(a.name)}</span>`).join('')}\n </div>\n </div>\n ` : ''}\n </div>\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 <div class=\"results-header\">\n <h2>ListenBrainz Playlists</h2>\n <span class=\"results-count\">${playlistsData.count} playlists</span>\n </div>\n <div class=\"results-grid\" id=\"lb-playlists-grid\">\n `;\n\n for (const playlist of playlistsData.playlists) {\n html += `\n <div class=\"album-card lb-playlist-card\" data-id=\"${playlist.id}\">\n <div class=\"album-card-art-container\">\n <img class=\"album-card-art\" src=\"/static/icon.svg\" alt=\"${escapeHtml(playlist.name)}\" loading=\"lazy\">\n <span class=\"hires-badge\" style=\"background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);\">LB</span>\n </div>\n <div class=\"album-card-info\">\n <p class=\"album-card-title\">${escapeHtml(playlist.name)}</p>\n <p class=\"album-card-artist\">${escapeHtml(playlist.artists)}</p>\n <div class=\"album-card-meta\">\n <span>${playlist.total_tracks || '?'} tracks</span>\n </div>\n </div>\n </div>\n `;\n }\n\n html += '</div>';\n } else {\n html += `\n <div class=\"results-header\">\n <h2>ListenBrainz Playlists</h2>\n </div>\n <div class=\"empty-state\" style=\"margin-bottom: 24px;\">\n <div class=\"empty-icon\">📋</div>\n <p class=\"empty-text\">No playlists found. Weekly Exploration playlists are generated on Mondays!</p>\n </div>\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 <div class=\"winamp-player\">\n <div class=\"winamp-titlebar\">\n <div style=\"width:10px; height:10px; background:#fff; margin-right:4px; clip-path: polygon(50% 0, 0 100%, 100% 100%);\"></div>\n <span class=\"winamp-titlebar-text\">FREEDIFY</span>\n <span style=\"flex:1\"></span>\n <div style=\"background:#808080; width:8px; height:8px; border:1px solid #fff; cursor:pointer;\" onclick=\"window.close()\"></div>\n </div>\n <div class=\"winamp-main\">\n <div class=\"winamp-art\">\n <img id=\"wa-art\" src=\"\" />\n </div>\n <div class=\"winamp-content\">\n <div class=\"winamp-display\">\n <div id=\"wa-time\" class=\"winamp-time\">00:00</div>\n <div id=\"wa-marquee\" class=\"winamp-marquee\"><span>Ready to Llama...</span></div>\n <div class=\"winamp-info-line\">\n <span id=\"wa-format\" style=\"color:#00e000; font-weight:bold;\">MP3</span>\n <span id=\"wa-state\" style=\"margin-left:8px;\">STOP</span>\n </div>\n </div>\n <div class=\"winamp-controls\">\n <div class=\"winamp-btn\" id=\"wa-prev\" title=\"Prev\"><svg viewBox=\"0 0 24 24\"><path d=\"M6 6h2v12H6zm3.5 6l8.5 6V6z\"/></svg></div>\n <div class=\"winamp-btn\" id=\"wa-play\" title=\"Play\"><svg viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\"/></svg></div>\n <div class=\"winamp-btn\" id=\"wa-pause\" title=\"Pause\"><svg viewBox=\"0 0 24 24\"><path d=\"M6 19h4V5H6v14zm8-14v14h4V5h-4z\"/></svg></div>\n <div class=\"winamp-btn\" id=\"wa-next\" title=\"Next\"><svg viewBox=\"0 0 24 24\"><path d=\"M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z\"/></svg></div>\n <div style=\"font-size: 8px; color: #fff; margin-left: 4px; font-family: 'Courier New', monospace;\">VOL</div>\n <input type=\"range\" class=\"winamp-slider\" id=\"wa-vol\" title=\"Volume Control\" min=\"0\" max=\"100\" value=\"${state.volume * 100}\">\n </div>\n </div>\n </div>\n </div>\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 = '<div class=\"ai-loading\">Generating playlist</div>';\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 <div class=\"ai-results-header\">\n <span>${data.playlist_name || 'Generated Playlist'}</span>\n <span>${data.tracks.length} tracks</span>\n </div>\n `;\n\n data.tracks.forEach((track, i) => {\n html += `\n <div class=\"ai-track-item\" data-artist=\"${escapeHtml(track.artist)}\" data-title=\"${escapeHtml(track.title)}\">\n <span style=\"color: var(--text-tertiary); width: 24px;\">${i + 1}</span>\n <div class=\"ai-track-info\">\n <div class=\"ai-track-title\">${escapeHtml(track.title)}</div>\n <div class=\"ai-track-artist\">${escapeHtml(track.artist)}</div>\n </div>\n </div>\n `;\n });\n\n html += '<button class=\"ai-add-all-btn\" id=\"ai-add-all\">Add All to Queue</button>';\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 = '<p style=\"color: var(--text-secondary);\">Could not generate playlist. Try a different description.</p>';\n }\n } catch (e) {\n console.error('Playlist generation error:', e);\n aiPlaylistResults.innerHTML = '<p style=\"color: var(--error);\">Playlist generation failed. Please try again.</p>';\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 <div class=\"ai-modal-overlay\"></div>\n <div class=\"ai-modal-content\">\n <div class=\"ai-modal-header\">\n <h2>🔗 Sync Devices</h2>\n <button class=\"ai-modal-close\" id=\"sync-modal-close\" title=\"Close\">×</button>\n </div>\n <div class=\"sync-panel\">\n <div class=\"sync-header\">\n <span>Enable Sync</span>\n <label class=\"sync-switch\">\n <input type=\"checkbox\" id=\"sync-toggle\" ${state.syncEnabled ? 'checked' : ''} />\n <span class=\"sync-switch-slider\"></span>\n </label>\n </div>\n <div class=\"sync-role-section\">\n <label class=\"sync-role-label\">This device is:</label>\n <div class=\"sync-role-buttons\">\n <button class=\"sync-role-btn ${state.syncRole === 'player' ? 'active' : ''}\" data-role=\"player\">🔊 Speaker</button>\n <button class=\"sync-role-btn ${state.syncRole === 'remote' ? 'active' : ''}\" data-role=\"remote\">📱 Remote</button>\n </div>\n <p class=\"sync-role-hint\" id=\"sync-role-hint\">${state.syncRole === 'remote' ? 'Audio plays on the other device. This device controls playback.' : 'Audio plays here. Other devices act as remotes.'}</p>\n </div>\n <div class=\"sync-devices-list\" id=\"sync-devices-list\">\n <p class=\"sync-hint\">Toggle on to discover devices on your network</p>\n </div>\n <div class=\"sync-manual\">\n <input type=\"text\" id=\"sync-manual-ip\" placeholder=\"Enter IP (e.g., 192.168.1.100:8000)\"\n value=\"${escapeHtml(localStorage.getItem('freedify_sync_last_ip') || '')}\" />\n <button id=\"sync-manual-connect\">Connect</button>\n </div>\n </div>\n </div>\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 = '<p class=\"sync-hint\">No devices found. Try manual IP.</p>';\n return;\n }\n // escapeHtml on mDNS-provided values to prevent injection (CLAUDE.md rule)\n list.innerHTML = devices.map(d => `\n <div class=\"sync-device\" onclick=\"window.connectSyncDevice('http://${escapeHtml(d.ip)}:${escapeHtml(String(d.port))}')\">\n <span class=\"sync-device-name\">${escapeHtml(d.name)}</span>\n <span class=\"sync-device-ip\">${escapeHtml(d.ip)}:${escapeHtml(String(d.port))}</span>\n </div>\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"}}}}