From c23f858412c1a0adf6ee0c0bbe0fea65f0b3eba9 Mon Sep 17 00:00:00 2001 From: IsraelGPT Date: Sun, 15 Feb 2026 16:30:54 +0000 Subject: [PATCH] JSPF playlist imports + plausible --- index.html | 116 ++++++-- js/analytics.js | 665 ++++++++++++++++++++++++++++++++++++++++++ js/app.js | 396 ++++++++++++++++++++++++- js/events.js | 105 ++++++- js/side-panel.js | 16 + js/ui-interactions.js | 5 + js/ui.js | 12 + todo.md | 3 - 8 files changed, 1275 insertions(+), 43 deletions(-) create mode 100644 js/analytics.js diff --git a/index.html b/index.html index 6070a69..75e07c0 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,22 @@ rel="stylesheet" /> + + + + @@ -410,7 +426,7 @@ >
-

Import from CSV

-

- Spotify and Apple Music are supported. (Apple Music is prone to errors.) Please use - Exportify (Spotify) or - TuneMyMusic (Apple Music) + + +

+ +
+

Import from CSV

+

+ Spotify and Apple Music are supported. (Apple Music is prone to errors.) Please use + Exportify (Spotify) + or + TuneMyMusic (Apple Music) + to export your playlist into a .csv. Make sure its headers are in English. +

+
+ +
+ + +

Warning: This feature isn't perfect and is prone to errors! Please check your playlist after to remove any unwanted songs that were added by the system. diff --git a/js/analytics.js b/js/analytics.js new file mode 100644 index 0000000..d1d33bc --- /dev/null +++ b/js/analytics.js @@ -0,0 +1,665 @@ +// js/analytics.js - Plausible Analytics custom event tracking + +/** + * Track a custom event with Plausible + * @param {string} eventName - The name of the event + * @param {object} [props] - Optional event properties + */ +export function trackEvent(eventName, props = {}) { + if (window.plausible) { + try { + window.plausible(eventName, { props }); + } catch { + // Silently fail if analytics is blocked + } + } +} + +/** + * Track page views with custom properties + * @param {string} path - The page path + */ +export function trackPageView(path) { + trackEvent('pageview', { path }); +} + +// Playback Events +export function trackPlayTrack(track) { + trackEvent('Play Track', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + album: track?.album?.title || 'Unknown', + duration: track?.duration || 0, + quality: track?.audioQuality || track?.quality || 'Unknown', + is_local: track?.isLocal || false, + }); +} + +export function trackPauseTrack(track) { + trackEvent('Pause Track', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + }); +} + +export function trackSkipTrack(track, direction) { + trackEvent('Skip Track', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + direction: direction, + }); +} + +export function trackToggleShuffle(enabled) { + trackEvent('Toggle Shuffle', { enabled }); +} + +export function trackToggleRepeat(mode) { + trackEvent('Toggle Repeat', { mode }); +} + +export function trackSetVolume(level) { + // Only track volume changes at coarse intervals to avoid spam + const roundedLevel = Math.round(level * 10) / 10; + trackEvent('Set Volume', { level: roundedLevel }); +} + +export function trackToggleMute(muted) { + trackEvent('Toggle Mute', { muted }); +} + +export function trackSeek(position, duration) { + const progress = duration ? Math.round((position / duration) * 100) : 0; + // Track seek at 25%, 50%, 75% milestones + if (progress >= 25 && progress < 30) { + trackEvent('Seek', { milestone: '25%', position }); + } else if (progress >= 50 && progress < 55) { + trackEvent('Seek', { milestone: '50%', position }); + } else if (progress >= 75 && progress < 80) { + trackEvent('Seek', { milestone: '75%', position }); + } +} + +// Search Events +export function trackSearch(query, resultsCount) { + trackEvent('Search', { + query_length: query?.length || 0, + has_results: resultsCount > 0, + results_count: resultsCount, + }); +} + +export function trackSearchTabChange(tab) { + trackEvent('Search Tab Change', { tab }); +} + +// Navigation Events +export function trackNavigate(path, pageType) { + trackEvent('Navigate', { + path, + page_type: pageType, + }); +} + +export function trackSidebarNavigation(item) { + trackEvent('Sidebar Navigation', { item }); +} + +// Library Events +export function trackLikeTrack(track) { + trackEvent('Like Track', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + }); +} + +export function trackUnlikeTrack(track) { + trackEvent('Unlike Track', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + }); +} + +export function trackLikeAlbum(album) { + trackEvent('Like Album', { + album_title: album?.title || 'Unknown', + artist: album?.artist?.name || 'Unknown', + }); +} + +export function trackUnlikeAlbum(album) { + trackEvent('Unlike Album', { + album_title: album?.title || 'Unknown', + }); +} + +export function trackLikeArtist(artist) { + trackEvent('Like Artist', { + artist_name: artist?.name || 'Unknown', + }); +} + +export function trackUnlikeArtist(artist) { + trackEvent('Unlike Artist', { + artist_name: artist?.name || 'Unknown', + }); +} + +export function trackLikePlaylist(playlist) { + trackEvent('Like Playlist', { + playlist_name: playlist?.title || playlist?.name || 'Unknown', + }); +} + +export function trackUnlikePlaylist(playlist) { + trackEvent('Unlike Playlist', { + playlist_name: playlist?.title || playlist?.name || 'Unknown', + }); +} + +// Playlist Management Events +export function trackCreatePlaylist(playlist, source) { + trackEvent('Create Playlist', { + playlist_name: playlist?.name || 'Unknown', + track_count: playlist?.tracks?.length || 0, + is_public: playlist?.isPublic || false, + source: source || 'manual', + }); +} + +export function trackEditPlaylist(playlist) { + trackEvent('Edit Playlist', { + playlist_name: playlist?.name || 'Unknown', + }); +} + +export function trackDeletePlaylist(playlistName) { + trackEvent('Delete Playlist', { playlist_name: playlistName }); +} + +export function trackAddToPlaylist(track, playlist) { + trackEvent('Add to Playlist', { + track_title: track?.title || 'Unknown', + playlist_name: playlist?.name || 'Unknown', + }); +} + +export function trackRemoveFromPlaylist(track, playlist) { + trackEvent('Remove from Playlist', { + track_title: track?.title || 'Unknown', + playlist_name: playlist?.name || 'Unknown', + }); +} + +export function trackCreateFolder(folder) { + trackEvent('Create Folder', { + folder_name: folder?.name || 'Unknown', + }); +} + +export function trackDeleteFolder(folderName) { + trackEvent('Delete Folder', { folder_name: folderName }); +} + +// Playback Actions +export function trackPlayAlbum(album, shuffle) { + trackEvent('Play Album', { + album_title: album?.title || 'Unknown', + artist: album?.artist?.name || 'Unknown', + shuffle: shuffle || false, + track_count: album?.numberOfTracks || album?.tracks?.length || 0, + }); +} + +export function trackPlayPlaylist(playlist, shuffle) { + trackEvent('Play Playlist', { + playlist_name: playlist?.title || playlist?.name || 'Unknown', + shuffle: shuffle || false, + track_count: playlist?.tracks?.length || 0, + }); +} + +export function trackPlayArtistRadio(artist) { + trackEvent('Play Artist Radio', { + artist_name: artist?.name || 'Unknown', + }); +} + +export function trackShuffleLikedTracks(count) { + trackEvent('Shuffle Liked Tracks', { track_count: count }); +} + +// Download Events +export function trackDownloadTrack(track, quality) { + trackEvent('Download Track', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + quality: quality || 'Unknown', + }); +} + +export function trackDownloadAlbum(album, quality) { + trackEvent('Download Album', { + album_title: album?.title || 'Unknown', + artist: album?.artist?.name || 'Unknown', + track_count: album?.numberOfTracks || album?.tracks?.length || 0, + quality: quality || 'Unknown', + }); +} + +export function trackDownloadPlaylist(playlist, quality) { + trackEvent('Download Playlist', { + playlist_name: playlist?.title || playlist?.name || 'Unknown', + track_count: playlist?.tracks?.length || 0, + quality: quality || 'Unknown', + }); +} + +export function trackDownloadLikedTracks(count, quality) { + trackEvent('Download Liked Tracks', { + track_count: count, + quality: quality || 'Unknown', + }); +} + +export function trackDownloadDiscography(artist, selection) { + trackEvent('Download Discography', { + artist_name: artist?.name || 'Unknown', + include_albums: selection?.includeAlbums || false, + include_eps: selection?.includeEPs || false, + include_singles: selection?.includeSingles || false, + }); +} + +// Queue Management +export function trackAddToQueue(track, position) { + trackEvent('Add to Queue', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + position: position || 'end', + }); +} + +export function trackPlayNext(track) { + trackEvent('Play Next', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + }); +} + +export function trackClearQueue() { + trackEvent('Clear Queue'); +} + +export function trackShuffleQueue() { + trackEvent('Shuffle Queue'); +} + +// Context Menu Actions +export function trackContextMenuAction(action, itemType, item) { + trackEvent('Context Menu Action', { + action, + item_type: itemType, + item_name: item?.title || item?.name || 'Unknown', + }); +} + +export function trackBlockTrack(track) { + trackEvent('Block Track', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + }); +} + +export function trackUnblockTrack(track) { + trackEvent('Unblock Track', { + track_title: track?.title || 'Unknown', + }); +} + +export function trackBlockAlbum(album) { + trackEvent('Block Album', { + album_title: album?.title || 'Unknown', + }); +} + +export function trackUnblockAlbum(album) { + trackEvent('Unblock Album', { + album_title: album?.title || 'Unknown', + }); +} + +export function trackBlockArtist(artist) { + trackEvent('Block Artist', { + artist_name: artist?.name || 'Unknown', + }); +} + +export function trackUnblockArtist(artist) { + trackEvent('Unblock Artist', { + artist_name: artist?.name || 'Unknown', + }); +} + +export function trackCopyLink(type, id) { + trackEvent('Copy Link', { type, id }); +} + +export function trackOpenInNewTab(type, id) { + trackEvent('Open in New Tab', { type, id }); +} + +// Lyrics Events +export function trackOpenLyrics(track) { + trackEvent('Open Lyrics', { + track_title: track?.title || 'Unknown', + artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', + }); +} + +export function trackCloseLyrics(track) { + trackEvent('Close Lyrics', { + track_title: track?.title || 'Unknown', + }); +} + +// Fullscreen/Cover View Events +export function trackOpenFullscreenCover(track) { + trackEvent('Open Fullscreen Cover', { + track_title: track?.title || 'Unknown', + }); +} + +export function trackCloseFullscreenCover() { + trackEvent('Close Fullscreen Cover'); +} + +export function trackToggleVisualizer(enabled) { + trackEvent('Toggle Visualizer', { enabled }); +} + +export function trackToggleLyricsFullscreen(enabled) { + trackEvent('Toggle Lyrics Fullscreen', { enabled }); +} + +// Settings Events +export function trackChangeSetting(setting, value) { + trackEvent('Change Setting', { + setting, + value: typeof value === 'object' ? JSON.stringify(value) : String(value), + }); +} + +export function trackChangeTheme(theme) { + trackEvent('Change Theme', { theme }); +} + +export function trackChangeQuality(type, quality) { + trackEvent('Change Quality', { type, quality }); +} + +export function trackChangeVolume(volume) { + trackEvent('Change Volume', { volume: Math.round(volume * 100) }); +} + +export function trackToggleScrobbler(service, enabled) { + trackEvent('Toggle Scrobbler', { service, enabled }); +} + +export function trackConnectScrobbler(service) { + trackEvent('Connect Scrobbler', { service }); +} + +export function trackDisconnectScrobbler(service) { + trackEvent('Disconnect Scrobbler', { service }); +} + +// Local Files Events +export function trackSelectLocalFolder(fileCount) { + trackEvent('Select Local Folder', { file_count: fileCount }); +} + +export function trackPlayLocalFile(track) { + trackEvent('Play Local File', { + track_title: track?.title || 'Unknown', + }); +} + +export function trackChangeLocalFolder() { + trackEvent('Change Local Folder'); +} + +// Import/Export Events +export function trackImportCSV(playlistName, trackCount, missingCount) { + trackEvent('Import CSV', { + playlist_name: playlistName, + track_count: trackCount, + missing_count: missingCount, + }); +} + +export function trackImportJSPF(playlistName, trackCount, missingCount, source) { + trackEvent('Import JSPF', { + playlist_name: playlistName, + track_count: trackCount, + missing_count: missingCount, + source: source || 'unknown', + }); +} + +export function trackExportPlaylist(playlist) { + trackEvent('Export Playlist', { + playlist_name: playlist?.title || playlist?.name || 'Unknown', + track_count: playlist?.tracks?.length || 0, + }); +} + +// Sleep Timer Events +export function trackSetSleepTimer(minutes) { + trackEvent('Set Sleep Timer', { minutes }); +} + +export function trackCancelSleepTimer() { + trackEvent('Cancel Sleep Timer'); +} + +// History Events +export function trackClearHistory() { + trackEvent('Clear History'); +} + +export function trackClearRecent() { + trackEvent('Clear Recent'); +} + +// Casting Events +export function trackStartCasting(deviceType) { + trackEvent('Start Casting', { device_type: deviceType }); +} + +export function trackStopCasting() { + trackEvent('Stop Casting'); +} + +// Keyboard Shortcuts +export function trackKeyboardShortcut(key) { + trackEvent('Keyboard Shortcut', { key }); +} + +// Pinning Events +export function trackPinItem(type, item) { + trackEvent('Pin Item', { + type, + item_name: item?.title || item?.name || 'Unknown', + }); +} + +export function trackUnpinItem(type, item) { + trackEvent('Unpin Item', { + type, + item_name: item?.title || item?.name || 'Unknown', + }); +} + +// Side Panel Events +export function trackOpenSidePanel(panelType) { + trackEvent('Open Side Panel', { panel_type: panelType }); +} + +export function trackCloseSidePanel() { + trackEvent('Close Side Panel'); +} + +// Queue Panel Events +export function trackOpenQueue() { + trackEvent('Open Queue'); +} + +export function trackCloseQueue() { + trackEvent('Close Queue'); +} + +// Mix Events +export function trackStartMix(sourceType, source) { + trackEvent('Start Mix', { + source_type: sourceType, + source_name: source?.title || source?.name || 'Unknown', + }); +} + +export function trackPlayMix(mixId) { + trackEvent('Play Mix', { mix_id: mixId }); +} + +// Search History Events +export function trackClearSearchHistory() { + trackEvent('Clear Search History'); +} + +export function trackClickSearchHistory(query) { + trackEvent('Click Search History', { query_length: query?.length || 0 }); +} + +// PWA/Update Events +export function trackPwaInstall() { + trackEvent('PWA Install'); +} + +export function trackPwaUpdate() { + trackEvent('PWA Update'); +} + +export function trackDismissUpdate() { + trackEvent('Dismiss Update'); +} + +// Sort Events +export function trackChangeSort(sortType) { + trackEvent('Change Sort', { sort_type: sortType }); +} + +// Modal Events +export function trackOpenModal(modalName) { + trackEvent('Open Modal', { modal_name: modalName }); +} + +export function trackCloseModal(modalName) { + trackEvent('Close Modal', { modal_name: modalName }); +} + +// Sharing Events +export function trackSharePlaylist(playlist, isPublic) { + trackEvent('Share Playlist', { + playlist_name: playlist?.name || 'Unknown', + is_public: isPublic, + }); +} + +// Audio Effects Events +export function trackChangePlaybackSpeed(speed) { + trackEvent('Change Playback Speed', { speed }); +} + +export function trackToggleReplayGain(mode) { + trackEvent('Toggle ReplayGain', { mode }); +} + +export function trackChangeEqualizer(preset) { + trackEvent('Change Equalizer', { preset }); +} + +// Waveform Events +export function trackToggleWaveform(enabled) { + trackEvent('Toggle Waveform', { enabled }); +} + +// Error Events +export function trackPlaybackError(errorType, track) { + trackEvent('Playback Error', { + error_type: errorType, + track_title: track?.title || 'Unknown', + }); +} + +export function trackSearchError(query) { + trackEvent('Search Error', { query_length: query?.length || 0 }); +} + +export function trackApiError(endpoint) { + trackEvent('API Error', { endpoint }); +} + +// Feature Discovery Events +export function trackViewFeature(feature) { + trackEvent('View Feature', { feature }); +} + +export function trackUseFeature(feature) { + trackEvent('Use Feature', { feature }); +} + +// Session Events +export function trackSessionStart() { + trackEvent('Session Start', { + user_agent: navigator.userAgent, + screen_width: window.screen.width, + screen_height: window.screen.height, + language: navigator.language, + }); +} + +export function trackSessionEnd(duration) { + trackEvent('Session End', { duration }); +} + +// Initialize analytics on page load +export function initAnalytics() { + // Track initial page view + trackPageView(window.location.pathname); + + // Track session start + trackSessionStart(); + + // Track navigation changes + let lastPath = window.location.pathname; + setInterval(() => { + const currentPath = window.location.pathname; + if (currentPath !== lastPath) { + trackPageView(currentPath); + lastPath = currentPath; + } + }, 500); + + // Track online/offline status + window.addEventListener('online', () => trackEvent('Go Online')); + window.addEventListener('offline', () => trackEvent('Go Offline')); + + // Track visibility changes (app focus/blur) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + trackEvent('App Background'); + } else { + trackEvent('App Foreground'); + } + }); +} diff --git a/js/app.js b/js/app.js index f8fcd56..9d2e52e 100644 --- a/js/app.js +++ b/js/app.js @@ -23,6 +23,39 @@ import { registerSW } from 'virtual:pwa-register'; import './smooth-scrolling.js'; import { initTracker } from './tracker.js'; +import { + initAnalytics, + trackNavigate, + trackSidebarNavigation, + trackCreatePlaylist, + trackEditPlaylist, + trackDeletePlaylist, + trackCreateFolder, + trackDeleteFolder, + trackImportCSV, + trackImportJSPF, + trackSelectLocalFolder, + trackChangeLocalFolder, + trackPlayAlbum, + trackShuffleLikedTracks, + trackDownloadLikedTracks, + trackDownloadDiscography, + trackOpenModal, + trackCloseModal, + trackClearHistory, + trackClearRecent, + trackKeyboardShortcut, + trackPwaUpdate, + trackDismissUpdate, + trackOpenFullscreenCover, + trackCloseFullscreenCover, + trackToggleLyricsFullscreen, + trackPlayPlaylist, + trackPlayArtistRadio, + trackOpenLyrics, + trackCloseLyrics, + trackContextMenuAction, +} from './analytics.js'; // Lazy-loaded modules let settingsModule = null; @@ -130,52 +163,66 @@ function initializeKeyboardShortcuts(player, audioPlayer) { switch (e.key.toLowerCase()) { case ' ': e.preventDefault(); + trackKeyboardShortcut('Space'); player.handlePlayPause(); break; case 'arrowright': if (e.shiftKey) { + trackKeyboardShortcut('Shift+Right'); player.playNext(); } else { + trackKeyboardShortcut('Right'); audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10); } break; case 'arrowleft': if (e.shiftKey) { + trackKeyboardShortcut('Shift+Left'); player.playPrev(); } else { + trackKeyboardShortcut('Left'); audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10); } break; case 'arrowup': e.preventDefault(); + trackKeyboardShortcut('Up'); player.setVolume(player.userVolume + 0.1); break; case 'arrowdown': e.preventDefault(); + trackKeyboardShortcut('Down'); player.setVolume(player.userVolume - 0.1); break; case 'm': + trackKeyboardShortcut('M'); audioPlayer.muted = !audioPlayer.muted; break; case 's': + trackKeyboardShortcut('S'); document.getElementById('shuffle-btn')?.click(); break; case 'r': + trackKeyboardShortcut('R'); document.getElementById('repeat-btn')?.click(); break; case 'q': + trackKeyboardShortcut('Q'); document.getElementById('queue-btn')?.click(); break; case '/': e.preventDefault(); + trackKeyboardShortcut('/'); document.getElementById('search-input')?.focus(); break; case 'escape': + trackKeyboardShortcut('Escape'); document.getElementById('search-input')?.blur(); sidePanelManager.close(); clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); break; case 'l': + trackKeyboardShortcut('L'); document.querySelector('.now-playing-bar .cover')?.click(); break; } @@ -230,6 +277,9 @@ async function disablePwaForAuthGate() { } document.addEventListener('DOMContentLoaded', async () => { + // Initialize analytics + initAnalytics(); + const api = new MusicAPI(apiSettings); const audioPlayer = document.getElementById('audio-player'); @@ -313,6 +363,17 @@ document.addEventListener('DOMContentLoaded', async () => { const { initializeSettings } = await loadSettingsModule(); initializeSettings(scrobbler, player, api, ui); + // Track sidebar navigation clicks + document.querySelectorAll('.sidebar-nav a').forEach((link) => { + link.addEventListener('click', (e) => { + const href = link.getAttribute('href'); + if (href && !href.startsWith('http')) { + const item = link.querySelector('span')?.textContent || href; + trackSidebarNavigation(item); + } + }); + }); + initializePlayerEvents(player, audioPlayer, scrobbler, ui); initializeTrackInteractions( player, @@ -339,6 +400,23 @@ document.addEventListener('DOMContentLoaded', async () => { const mode = nowPlayingSettings.getMode(); + if (mode === 'lyrics') { + const isActive = sidePanelManager.isActive('lyrics'); + + if (isActive) { + trackCloseLyrics(player.currentTrack); + } else { + trackOpenLyrics(player.currentTrack); + } + } else if (mode === 'cover') { + const overlay = document.getElementById('fullscreen-cover-overlay'); + if (overlay && overlay.style.display === 'flex') { + trackCloseFullscreenCover(); + } else { + trackOpenFullscreenCover(player.currentTrack); + } + } + if (mode === 'lyrics') { const isActive = sidePanelManager.isActive('lyrics'); @@ -375,6 +453,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => { + trackCloseFullscreenCover(); if (window.location.hash === '#fullscreen') { window.history.back(); } else { @@ -403,6 +482,32 @@ document.addEventListener('DOMContentLoaded', async () => { sidebarSettings.setCollapsed(isCollapsed); }); + // Import tab switching in playlist modal + document.querySelectorAll('.import-tab').forEach((tab) => { + tab.addEventListener('click', () => { + const importType = tab.dataset.importType; + + // Update tab styles + document.querySelectorAll('.import-tab').forEach((t) => { + t.classList.remove('active'); + t.style.opacity = '0.7'; + }); + tab.classList.add('active'); + tab.style.opacity = '1'; + + // Show/hide panels + document.getElementById('csv-import-panel').style.display = importType === 'csv' ? 'block' : 'none'; + document.getElementById('jspf-import-panel').style.display = importType === 'jspf' ? 'block' : 'none'; + + // Clear the other file input + if (importType === 'csv') { + document.getElementById('jspf-file-input').value = ''; + } else { + document.getElementById('csv-file-input').value = ''; + } + }); + }); + document.getElementById('nav-back')?.addEventListener('click', () => { window.history.back(); }); @@ -622,14 +727,23 @@ document.addEventListener('DOMContentLoaded', async () => { } if (e.target.closest('#create-playlist-btn')) { + trackOpenModal('Create Playlist'); const modal = document.getElementById('playlist-modal'); document.getElementById('playlist-modal-title').textContent = 'Create Playlist'; document.getElementById('playlist-name-input').value = ''; document.getElementById('playlist-cover-input').value = ''; document.getElementById('playlist-description-input').value = ''; modal.dataset.editingId = ''; - document.getElementById('csv-import-section').style.display = 'block'; + document.getElementById('import-section').style.display = 'block'; document.getElementById('csv-file-input').value = ''; + document.getElementById('jspf-file-input').value = ''; + + // Reset import tabs to CSV + document.querySelectorAll('.import-tab').forEach((tab) => { + tab.classList.toggle('active', tab.dataset.importType === 'csv'); + }); + document.getElementById('csv-import-panel').style.display = 'block'; + document.getElementById('jspf-import-panel').style.display = 'none'; // Reset Public Toggle const publicToggle = document.getElementById('playlist-public-toggle'); @@ -642,6 +756,7 @@ document.addEventListener('DOMContentLoaded', async () => { } if (e.target.closest('#create-folder-btn')) { + trackOpenModal('Create Folder'); const modal = document.getElementById('folder-modal'); document.getElementById('folder-name-input').value = ''; document.getElementById('folder-cover-input').value = ''; @@ -655,9 +770,11 @@ document.addEventListener('DOMContentLoaded', async () => { if (name) { const folder = await db.createFolder(name, cover); + trackCreateFolder(folder); await syncManager.syncUserFolder(folder, 'create'); ui.renderLibraryPage(); document.getElementById('folder-modal').classList.remove('active'); + trackCloseModal('Create Folder'); } } @@ -676,8 +793,8 @@ document.addEventListener('DOMContentLoaded', async () => { } if (e.target.closest('#playlist-modal-save')) { - const name = document.getElementById('playlist-name-input').value.trim(); - const description = document.getElementById('playlist-description-input').value.trim(); + let name = document.getElementById('playlist-name-input').value.trim(); + let description = document.getElementById('playlist-description-input').value.trim(); const isPublic = document.getElementById('playlist-public-toggle')?.checked; if (name) { @@ -726,10 +843,100 @@ document.addEventListener('DOMContentLoaded', async () => { } else { // Create const csvFileInput = document.getElementById('csv-file-input'); + const jspfFileInput = document.getElementById('jspf-file-input'); let tracks = []; + let importSource = 'manual'; + let cover = document.getElementById('playlist-cover-input').value.trim(); - if (csvFileInput.files.length > 0) { + if (jspfFileInput.files.length > 0) { + // Import from JSPF + importSource = 'jspf_import'; + const file = jspfFileInput.files[0]; + const progressElement = document.getElementById('csv-import-progress'); + const progressFill = document.getElementById('csv-progress-fill'); + const progressCurrent = document.getElementById('csv-progress-current'); + const progressTotal = document.getElementById('csv-progress-total'); + const currentTrackElement = progressElement.querySelector('.current-track'); + const currentArtistElement = progressElement.querySelector('.current-artist'); + + try { + // Show progress bar + progressElement.style.display = 'block'; + progressFill.style.width = '0%'; + progressCurrent.textContent = '0'; + currentTrackElement.textContent = 'Reading JSPF file...'; + if (currentArtistElement) currentArtistElement.textContent = ''; + + const jspfText = await file.text(); + + const result = await parseJSPF(jspfText, api, (progress) => { + const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + progressFill.style.width = `${Math.min(percentage, 100)}%`; + progressCurrent.textContent = progress.current.toString(); + progressTotal.textContent = progress.total.toString(); + currentTrackElement.textContent = progress.currentTrack; + if (currentArtistElement) + currentArtistElement.textContent = progress.currentArtist || ''; + }); + + tracks = result.tracks; + const missingTracks = result.missingTracks; + + if (tracks.length === 0) { + alert('No valid tracks found in the JSPF file! Please check the format.'); + progressElement.style.display = 'none'; + return; + } + console.log(`Imported ${tracks.length} tracks from JSPF`); + + // Auto-fill playlist metadata from JSPF if not provided + const jspfData = result.jspfData; + if (jspfData && jspfData.playlist) { + const playlist = jspfData.playlist; + if (!name && playlist.title) { + name = playlist.title; + } + if (!description && playlist.annotation) { + description = playlist.annotation; + } + if (!cover && playlist.image) { + cover = playlist.image; + } + } + + // Track JSPF import + const jspfPlaylist = result.jspfData?.playlist; + const jspfCreator = + jspfPlaylist?.creator || + jspfPlaylist?.extension?.['https://musicbrainz.org/doc/jspf#playlist']?.creator || + 'unknown'; + trackImportJSPF( + name || jspfPlaylist?.title || 'Untitled', + tracks.length, + missingTracks.length, + jspfCreator + ); + + // if theres missing songs, warn the user + if (missingTracks.length > 0) { + setTimeout(() => { + showMissingTracksNotification(missingTracks); + }, 500); + } + } catch (error) { + console.error('Failed to parse JSPF!', error); + alert('Failed to parse JSPF file! ' + error.message); + progressElement.style.display = 'none'; + return; + } finally { + // Hide progress bar + setTimeout(() => { + progressElement.style.display = 'none'; + }, 1000); + } + } else if (csvFileInput.files.length > 0) { // Import from CSV + importSource = 'csv_import'; const file = csvFileInput.files[0]; const progressElement = document.getElementById('csv-import-progress'); const progressFill = document.getElementById('csv-progress-fill'); @@ -789,8 +996,6 @@ document.addEventListener('DOMContentLoaded', async () => { } } - const cover = document.getElementById('playlist-cover-input').value.trim(); - // Check for pending tracks (from Add to Playlist -> New Playlist) const modal = document.getElementById('playlist-modal'); if (modal._pendingTracks && Array.isArray(modal._pendingTracks)) { @@ -805,8 +1010,10 @@ document.addEventListener('DOMContentLoaded', async () => { // Update DB again with isPublic flag await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await syncManager.syncUserPlaylist(playlist, 'create'); + trackCreatePlaylist(playlist, importSource); ui.renderLibraryPage(); modal.classList.remove('active'); + trackCloseModal('Create Playlist'); }); } } @@ -844,7 +1051,7 @@ document.addEventListener('DOMContentLoaded', async () => { } modal.dataset.editingId = playlistId; - document.getElementById('csv-import-section').style.display = 'none'; + document.getElementById('import-section').style.display = 'none'; modal.classList.add('active'); document.getElementById('playlist-name-input').focus(); } @@ -885,7 +1092,7 @@ document.addEventListener('DOMContentLoaded', async () => { } modal.dataset.editingId = playlistId; - document.getElementById('csv-import-section').style.display = 'none'; + document.getElementById('import-section').style.display = 'none'; modal.classList.add('active'); document.getElementById('playlist-name-input').focus(); } @@ -1048,7 +1255,7 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('playlist-name-input').value = ''; document.getElementById('playlist-cover-input').value = ''; createModal.dataset.editingId = ''; - document.getElementById('csv-import-section').style.display = 'none'; // Hide CSV for simple add + document.getElementById('import-section').style.display = 'none'; // Hide import for simple add // Pass tracks createModal._pendingTracks = tracks; @@ -1228,6 +1435,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Local Files Logic lollll if (e.target.closest('#select-local-folder-btn') || e.target.closest('#change-local-folder-btn')) { + const isChange = e.target.closest('#change-local-folder-btn') !== null; try { const handle = await window.showDirectoryPicker({ id: 'music-folder', @@ -1235,6 +1443,9 @@ document.addEventListener('DOMContentLoaded', async () => { }); await db.saveSetting('local_folder_handle', handle); + if (isChange) { + trackChangeLocalFolder(); + } const btn = document.getElementById('select-local-folder-btn'); const btnText = document.getElementById('select-local-folder-text'); @@ -1279,6 +1490,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); window.localFilesCache = tracks; + trackSelectLocalFolder(tracks.length); ui.renderLibraryPage(); } catch (err) { if (err.name !== 'AbortError') { @@ -1411,10 +1623,14 @@ document.addEventListener('DOMContentLoaded', async () => { onNeedRefresh() { if (pwaUpdateSettings.isAutoUpdateEnabled()) { // Auto-update: immediately activate the new service worker + trackPwaUpdate(); updateSW(true); } else { // Show notification with Update button and dismiss option - showUpdateNotification(() => updateSW(true)); + showUpdateNotification(() => { + trackPwaUpdate(); + updateSW(true); + }); } }, onOfflineReady() { @@ -1538,6 +1754,7 @@ function showUpdateNotification(updateCallback) { }); document.getElementById('dismiss-update-btn').addEventListener('click', () => { + trackDismissUpdate(); notification.remove(); }); } @@ -1860,6 +2077,165 @@ async function parseCSV(csvText, api, onProgress) { return { tracks, missingTracks }; } +async function parseJSPF(jspfText, api, onProgress) { + try { + const jspfData = JSON.parse(jspfText); + + if (!jspfData.playlist || !Array.isArray(jspfData.playlist.track)) { + throw new Error('Invalid JSPF format: missing playlist or track array'); + } + + const playlist = jspfData.playlist; + const tracks = []; + const missingTracks = []; + const totalTracks = playlist.track.length; + + // Helper: Normalize strings for fuzzy matching + const normalize = (str) => + str + ?.normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^\w\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() || ''; + + // Helper: Check if result matches our criteria + const isValidMatch = (track, title, artists, album) => { + if (!track) return false; + + const trackTitle = normalize(track.title || ''); + const trackArtists = (track.artists || []).map((a) => normalize(a.name || '')).join(' '); + const trackAlbum = normalize(track.album?.name || ''); + + const queryTitle = normalize(title); + const queryArtists = normalize(artists); + const queryAlbum = normalize(album || ''); + + // Must match title (exact or substring match) + const titleMatch = + trackTitle === queryTitle || trackTitle.includes(queryTitle) || queryTitle.includes(trackTitle); + if (!titleMatch) return false; + + // Must match at least one artist + const artistMatch = + trackArtists.includes(queryArtists.split(' ')[0]) || queryArtists.includes(trackArtists.split(' ')[0]); + if (!artistMatch) return false; + + // If album provided, prefer matching album but not strict + if (queryAlbum) { + const albumMatch = + trackAlbum === queryAlbum || trackAlbum.includes(queryAlbum) || queryAlbum.includes(trackAlbum); + return albumMatch; + } + + return true; + }; + + for (let i = 0; i < playlist.track.length; i++) { + const jspfTrack = playlist.track[i]; + const trackTitle = jspfTrack.title; + const trackCreator = jspfTrack.creator; + const trackAlbum = jspfTrack.album; + + // Support ListenBrainz extension data + const lbExtension = jspfTrack.extension?.['https://musicbrainz.org/doc/jspf#track']; + const mbRecordingId = lbExtension?.artist_identifiers?.[0]?.split('/').pop(); + + if (onProgress) { + onProgress({ + current: i, + total: totalTracks, + currentTrack: trackTitle || 'Unknown track', + currentArtist: trackCreator || '', + }); + } + + // Try to find track + let foundTrack = null; + + if (trackTitle && trackCreator) { + // Add delay to prevent rate limiting + await new Promise((resolve) => setTimeout(resolve, 300)); + + try { + // 1. Search with title + artist + album + let searchQuery = `${trackTitle} ${trackCreator}`; + if (trackAlbum) searchQuery += ` ${trackAlbum}`; + const searchResults = await api.searchTracks(searchQuery); + + if (searchResults.items && searchResults.items.length > 0) { + for (const result of searchResults.items) { + if (isValidMatch(result, trackTitle, trackCreator, trackAlbum)) { + foundTrack = result; + break; + } + } + } + + // 2. Retry with main artist only + if (!foundTrack) { + const mainArtist = trackCreator.split(',')[0].trim(); + if (mainArtist && mainArtist !== trackCreator) { + const searchResults = await api.searchTracks(`${trackTitle} ${mainArtist}`); + if (searchResults.items) { + for (const result of searchResults.items) { + if (isValidMatch(result, trackTitle, mainArtist, trackAlbum)) { + foundTrack = result; + break; + } + } + } + } + } + + // 3. Try just title + artist, ignoring album + if (!foundTrack) { + const searchResults = await api.searchTracks(`${trackTitle} ${trackCreator}`); + if (searchResults.items) { + for (const result of searchResults.items) { + if (isValidMatch(result, trackTitle, trackCreator, null)) { + foundTrack = result; + break; + } + } + } + } + + if (foundTrack) { + tracks.push(foundTrack); + console.log(`✓ "${trackTitle}" by ${trackCreator}`); + } else { + console.warn(`✗ Track not found: "${trackTitle}" by ${trackCreator}`); + missingTracks.push( + `${trackTitle} - ${trackCreator}${trackAlbum ? ' (' + trackAlbum + ')' : ''}` + ); + } + } catch (error) { + console.error(`Error searching for track "${trackTitle}":`, error); + missingTracks.push(`${trackTitle} - ${trackCreator}${trackAlbum ? ' (' + trackAlbum + ')' : ''}`); + } + } else { + missingTracks.push(`Invalid track entry at position ${i + 1}`); + } + } + + // Final progress update + if (onProgress) { + onProgress({ + current: totalTracks, + total: totalTracks, + currentTrack: 'Import complete', + }); + } + + return { tracks, missingTracks, jspfData }; + } catch (error) { + console.error('JSPF parsing error:', error); + throw new Error('Failed to parse JSPF file: ' + error.message); + } +} + function showDiscographyDownloadModal(artist, api, quality, lyricsManager, triggerBtn) { const modal = document.getElementById('discography-download-modal'); diff --git a/js/events.js b/js/events.js index d710fb8..63b6608 100644 --- a/js/events.js +++ b/js/events.js @@ -19,6 +19,45 @@ import { db } from './db.js'; import { syncManager } from './accounts/pocketbase.js'; import { waveformGenerator } from './waveform.js'; import { audioContextManager } from './audio-context.js'; +import { + trackPlayTrack, + trackPauseTrack, + trackSkipTrack, + trackToggleShuffle, + trackToggleRepeat, + trackToggleMute, + trackSeek, + trackAddToQueue, + trackPlayNext, + trackClearQueue, + trackLikeTrack, + trackUnlikeTrack, + trackLikeAlbum, + trackUnlikeAlbum, + trackLikeArtist, + trackUnlikeArtist, + trackLikePlaylist, + trackUnlikePlaylist, + trackDownloadTrack, + trackContextMenuAction, + trackBlockTrack, + trackUnblockTrack, + trackBlockAlbum, + trackUnblockAlbum, + trackBlockArtist, + trackUnblockArtist, + trackCopyLink, + trackOpenInNewTab, + trackSetSleepTimer, + trackCancelSleepTimer, + trackOpenSidePanel, + trackCloseSidePanel, + trackOpenQueue, + trackCloseQueue, + trackStartMix, + trackChangeSort, + trackToggleWaveform, +} from './analytics.js'; let currentTrackIdForWaveform = null; @@ -61,6 +100,9 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { audioContextManager.resume(); if (player.currentTrack) { + // Track play event + trackPlayTrack(player.currentTrack); + // Scrobble if (scrobbler.isAuthenticated()) { scrobbler.updateNowPlaying(player.currentTrack); @@ -81,6 +123,9 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { }); audioPlayer.addEventListener('pause', () => { + if (player.currentTrack) { + trackPauseTrack(player.currentTrack); + } playPauseBtn.innerHTML = SVG_PLAY; player.updateMediaSessionPlaybackState(); player.updateMediaSessionPositionState(); @@ -98,6 +143,9 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { progressFill.style.width = `${(currentTime / duration) * 100}%`; currentTimeEl.textContent = formatTime(currentTime); + // Track seek milestones + trackSeek(currentTime, duration); + // Log to history after 10 seconds of playback if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) { historyLoggedTrackId = player.currentTrack.id; @@ -173,17 +221,25 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { }); playPauseBtn.addEventListener('click', () => player.handlePlayPause()); - nextBtn.addEventListener('click', () => player.playNext()); - prevBtn.addEventListener('click', () => player.playPrev()); + nextBtn.addEventListener('click', () => { + trackSkipTrack(player.currentTrack, 'next'); + player.playNext(); + }); + prevBtn.addEventListener('click', () => { + trackSkipTrack(player.currentTrack, 'previous'); + player.playPrev(); + }); shuffleBtn.addEventListener('click', () => { player.toggleShuffle(); + trackToggleShuffle(player.shuffleActive); shuffleBtn.classList.toggle('active', player.shuffleActive); if (window.renderQueueFunction) window.renderQueueFunction(); }); repeatBtn.addEventListener('click', () => { const mode = player.toggleRepeat(); + trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one'); repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF); repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE); repeatBtn.title = @@ -195,6 +251,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { sleepTimerBtnDesktop.addEventListener('click', () => { if (player.isSleepTimerActive()) { player.clearSleepTimer(); + trackCancelSleepTimer(); showNotification('Sleep timer cancelled'); } else { showSleepTimerModal(player); @@ -207,6 +264,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { sleepTimerBtnMobile.addEventListener('click', () => { if (player.isSleepTimerActive()) { player.clearSleepTimer(); + trackCancelSleepTimer(); showNotification('Sleep timer cancelled'); } else { showSleepTimerModal(player); @@ -858,10 +916,12 @@ export async function handleTrackAction( } if (action === 'add-to-queue') { + trackAddToQueue(item, 'end'); player.addToQueue(item); if (window.renderQueueFunction) window.renderQueueFunction(); showNotification(`Added to queue: ${item.title}`); } else if (action === 'play-next') { + trackPlayNext(item); player.addNextToQueue(item); if (window.renderQueueFunction) window.renderQueueFunction(); showNotification(`Playing next: ${item.title}`); @@ -870,17 +930,32 @@ export async function handleTrackAction( player.playAtIndex(0); showNotification(`Playing track: ${item.title}`); } else if (action === 'start-mix') { + trackStartMix(type, item); if (item.mixes?.TRACK_MIX) { navigate(`/mix/${item.mixes.TRACK_MIX}`); } else { showNotification('No mix available for this track'); } } else if (action === 'download') { + trackDownloadTrack(item, downloadQualitySettings.getQuality()); await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager); } else if (action === 'toggle-like') { const added = await db.toggleFavorite(type, item); syncManager.syncLibraryItem(type, item, added); + // Track like/unlike + if (added) { + if (type === 'track') trackLikeTrack(item); + else if (type === 'album') trackLikeAlbum(item); + else if (type === 'artist') trackLikeArtist(item); + else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item); + } else { + if (type === 'track') trackUnlikeTrack(item); + else if (type === 'album') trackUnlikeAlbum(item); + else if (type === 'artist') trackUnlikeArtist(item); + else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item); + } + if (added && type === 'track' && scrobbler) { if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) { scrobbler.loveTrack(item); @@ -1088,6 +1163,7 @@ export async function handleTrackAction( ? `${window.location.origin}${storedHref}` : `${window.location.origin}/track/${item.id || item.uuid}`; + trackCopyLink(type, item.id || item.uuid); navigator.clipboard.writeText(url).then(() => { showNotification('Link copied to clipboard!'); }); @@ -1099,6 +1175,7 @@ export async function handleTrackAction( ? `${window.location.origin}${storedHref}` : `${window.location.origin}/track/${item.id || item.uuid}`; + trackOpenInNewTab(type, item.id || item.uuid); window.open(url, '_blank'); } else if (action === 'track-info') { // Show detailed track info modal @@ -1268,9 +1345,11 @@ export async function handleTrackAction( const { contentBlockingSettings } = await import('./storage.js'); if (contentBlockingSettings.isTrackBlocked(item.id)) { contentBlockingSettings.unblockTrack(item.id); + trackUnblockTrack(item); showNotification(`Unblocked track: ${item.title}`); } else { contentBlockingSettings.blockTrack(item); + trackBlockTrack(item); showNotification(`Blocked track: ${item.title}`); } } else if (action === 'block-album') { @@ -1284,15 +1363,15 @@ export async function handleTrackAction( return; } + const albumObj = { id: albumId, title: albumTitle, artist: albumArtist }; + if (contentBlockingSettings.isAlbumBlocked(albumId)) { contentBlockingSettings.unblockAlbum(albumId); + trackUnblockAlbum(albumObj); showNotification(`Unblocked album: ${albumTitle || 'Unknown Album'}`); } else { - contentBlockingSettings.blockAlbum({ - id: albumId, - title: albumTitle, - artist: albumArtist, - }); + contentBlockingSettings.blockAlbum(albumObj); + trackBlockAlbum(albumObj); showNotification(`Blocked album: ${albumTitle || 'Unknown Album'}`); } } else if (action === 'block-artist') { @@ -1305,14 +1384,15 @@ export async function handleTrackAction( return; } + const artistObj = { id: artistId, name: artistName }; + if (contentBlockingSettings.isArtistBlocked(artistId)) { contentBlockingSettings.unblockArtist(artistId); + trackUnblockArtist(artistObj); showNotification(`Unblocked artist: ${artistName || 'Unknown Artist'}`); } else { - contentBlockingSettings.blockArtist({ - id: artistId, - name: artistName, - }); + contentBlockingSettings.blockArtist(artistObj); + trackBlockArtist(artistObj); showNotification(`Blocked artist: ${artistName || 'Unknown Artist'}`); } } @@ -1630,6 +1710,8 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen const track = contextMenu._contextTrack || contextTrack; const type = contextMenu._contextType || 'track'; if (action && track) { + // Track context menu action + trackContextMenuAction(action, type, track); await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler); } contextMenu.style.display = 'none'; @@ -1784,6 +1866,7 @@ function showSleepTimerModal(player) { if (minutes) { player.setSleepTimer(minutes); + trackSetSleepTimer(minutes); showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`); closeModal(); } diff --git a/js/side-panel.js b/js/side-panel.js index 0c82e81..8ba2527 100644 --- a/js/side-panel.js +++ b/js/side-panel.js @@ -1,3 +1,5 @@ +import { trackCloseSidePanel, trackCloseQueue, trackCloseLyrics } from './analytics.js'; + export class SidePanelManager { constructor() { this.panel = document.getElementById('side-panel'); @@ -30,6 +32,20 @@ export class SidePanelManager { } close() { + // Track side panel close + if (this.currentView) { + trackCloseSidePanel(); + if (this.currentView === 'queue') { + trackCloseQueue(); + } else if (this.currentView === 'lyrics') { + // Get current track from audio player context + const audioPlayer = document.getElementById('audio-player'); + if (audioPlayer && audioPlayer._currentTrack) { + trackCloseLyrics(audioPlayer._currentTrack); + } + } + } + this.panel.classList.remove('active'); this.currentView = null; // Optionally clear content after transition diff --git a/js/ui-interactions.js b/js/ui-interactions.js index 3992914..465c2c1 100644 --- a/js/ui-interactions.js +++ b/js/ui-interactions.js @@ -16,6 +16,7 @@ import { downloadQualitySettings, contentBlockingSettings } from './storage.js'; import { db } from './db.js'; import { syncManager } from './accounts/pocketbase.js'; import { showNotification, downloadTracks } from './downloads.js'; +import { trackSearchTabChange, trackOpenQueue, trackCloseQueue, trackChangeSort } from './analytics.js'; export function initializeUIInteractions(player, api, ui) { const sidebar = document.querySelector('.sidebar'); @@ -386,6 +387,7 @@ export function initializeUIInteractions(player, api, ui) { }; const openQueuePanel = () => { + trackOpenQueue(); sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent); }; @@ -439,6 +441,9 @@ export function initializeUIInteractions(player, api, ui) { const page = tab.closest('.page'); if (!page) return; + // Track tab change + trackSearchTabChange(tab.dataset.tab); + page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active')); page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active')); diff --git a/js/ui.js b/js/ui.js index 5613747..8874a68 100644 --- a/js/ui.js +++ b/js/ui.js @@ -45,6 +45,13 @@ import { createProjectCardHTML, createTrackFromSong, } from './tracker.js'; +import { + trackSearch, + trackSearchTabChange, + trackClearSearchHistory, + trackClickSearchHistory, + trackChangeSort, +} from './analytics.js'; fontSettings.applyFont(); fontSettings.applyFontSize(); @@ -2012,6 +2019,10 @@ export class UIRenderer { finalAlbums = Array.from(albumMap.values()); } + // Track search with results + const totalResults = finalTracks.length + finalArtists.length + finalAlbums.length + finalPlaylists.length; + trackSearch(query, totalResults); + if (finalTracks.length) { this.renderListWithTracks(tracksContainer, finalTracks, true); } else { @@ -3275,6 +3286,7 @@ export class UIRenderer { const handleSort = (ev) => { const li = ev.target.closest('li'); if (li && li.dataset.sort) { + trackChangeSort(li.dataset.sort); onSort(li.dataset.sort); closeMenu(); } diff --git a/todo.md b/todo.md index 0820b50..b982ace 100644 --- a/todo.md +++ b/todo.md @@ -2,10 +2,7 @@ Sorted by ease of implementation (easiest to hardest): -- [ ] Update notifications: Add ability to show the update popup in settings, with an option to automatically update (enabled by default) - - [ ] effects like reverb, delay, and bitcrushing - [ ] Customizable EQ: Allow users to change the number of EQ bands and their range (-30 to 30), with a drag-to-adjust interface similar to FL Studio's velocity editor [ ] SoundCloud support: Integrate SoundCloud through SoundCloak -[ ] Qobuz support: Integrate Qobuz through Qobuz-DL