JSPF playlist imports + plausible

This commit is contained in:
IsraelGPT 2026-02-15 16:30:54 +00:00
parent 13b350edc8
commit c23f858412
8 changed files with 1275 additions and 43 deletions

View file

@ -45,6 +45,22 @@
rel="stylesheet" rel="stylesheet"
/> />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.canine.tools/js/pa-dCMvQpiD1-AJmi8o3xviO.js"></script>
<script>
window.plausible =
window.plausible ||
function () {
(plausible.q = plausible.q || []).push(arguments);
};
plausible.init =
plausible.init ||
function (i) {
plausible.o = i || {};
};
plausible.init();
</script>
</head> </head>
<body> <body>
@ -410,7 +426,7 @@
></textarea> ></textarea>
<br /> <br />
<div <div
id="csv-import-section" id="import-section"
style=" style="
display: none; display: none;
margin: 1rem 0; margin: 1rem 0;
@ -420,25 +436,87 @@
background: var(--background-secondary); background: var(--background-secondary);
" "
> >
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from CSV</p> <div
<p style="font-size: 0.8rem; margin: 0"> class="import-tabs"
Spotify and Apple Music are supported. (Apple Music is prone to errors.) Please use style="
<a href="https://exportify.app/" style="text-decoration: underline">Exportify (Spotify)</a> or display: flex;
<a gap: 0.5rem;
href="https://www.tunemymusic.com/transfer/spotify-to-apple-music" margin-bottom: 1rem;
style="text-decoration: underline" border-bottom: 1px solid var(--border);
>TuneMyMusic (Apple Music)</a padding-bottom: 0.5rem;
"
>
<button
type="button"
class="import-tab active"
data-import-type="csv"
style="
background: transparent;
border: none;
color: var(--foreground);
cursor: pointer;
padding: 0.25rem 0.5rem;
opacity: 0.7;
"
> >
to export your playlist into a .csv. Make sure its headers are in English. CSV
</p> </button>
<br /> <button
<input type="button"
type="file" class="import-tab"
id="csv-file-input" data-import-type="jspf"
class="btn-secondary" style="
accept=".csv" background: transparent;
style="width: 100%; margin-bottom: 0.5rem" border: none;
/> color: var(--foreground);
cursor: pointer;
padding: 0.25rem 0.5rem;
opacity: 0.7;
"
>
JSPF
</button>
</div>
<div id="csv-import-panel" class="import-panel active">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from CSV</p>
<p style="font-size: 0.8rem; margin: 0">
Spotify and Apple Music are supported. (Apple Music is prone to errors.) Please use
<a href="https://exportify.app/" style="text-decoration: underline">Exportify (Spotify)</a>
or
<a
href="https://www.tunemymusic.com/transfer/spotify-to-apple-music"
style="text-decoration: underline"
>TuneMyMusic (Apple Music)</a
>
to export your playlist into a .csv. Make sure its headers are in English.
</p>
<br />
<input
type="file"
id="csv-file-input"
class="btn-secondary"
accept=".csv"
style="width: 100%; margin-bottom: 0.5rem"
/>
</div>
<div id="jspf-import-panel" class="import-panel" style="display: none">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from JSPF</p>
<p style="font-size: 0.8rem; margin: 0">
JSPF (JSON Shareable Playlist Format) is supported by ListenBrainz and other services.
Import playlists with rich metadata including MusicBrainz identifiers.
</p>
<br />
<input
type="file"
id="jspf-file-input"
class="btn-secondary"
accept=".json,.jspf"
style="width: 100%; margin-bottom: 0.5rem"
/>
</div>
<p style="font-size: 0.8rem; margin: 0"> <p style="font-size: 0.8rem; margin: 0">
<b>Warning:</b> This feature isn't perfect and is prone to errors! Please check your playlist <b>Warning:</b> 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. after to remove any unwanted songs that were added by the system.

665
js/analytics.js Normal file
View file

@ -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');
}
});
}

396
js/app.js
View file

@ -23,6 +23,39 @@ import { registerSW } from 'virtual:pwa-register';
import './smooth-scrolling.js'; import './smooth-scrolling.js';
import { initTracker } from './tracker.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 // Lazy-loaded modules
let settingsModule = null; let settingsModule = null;
@ -130,52 +163,66 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case ' ': case ' ':
e.preventDefault(); e.preventDefault();
trackKeyboardShortcut('Space');
player.handlePlayPause(); player.handlePlayPause();
break; break;
case 'arrowright': case 'arrowright':
if (e.shiftKey) { if (e.shiftKey) {
trackKeyboardShortcut('Shift+Right');
player.playNext(); player.playNext();
} else { } else {
trackKeyboardShortcut('Right');
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10); audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10);
} }
break; break;
case 'arrowleft': case 'arrowleft':
if (e.shiftKey) { if (e.shiftKey) {
trackKeyboardShortcut('Shift+Left');
player.playPrev(); player.playPrev();
} else { } else {
trackKeyboardShortcut('Left');
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10); audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10);
} }
break; break;
case 'arrowup': case 'arrowup':
e.preventDefault(); e.preventDefault();
trackKeyboardShortcut('Up');
player.setVolume(player.userVolume + 0.1); player.setVolume(player.userVolume + 0.1);
break; break;
case 'arrowdown': case 'arrowdown':
e.preventDefault(); e.preventDefault();
trackKeyboardShortcut('Down');
player.setVolume(player.userVolume - 0.1); player.setVolume(player.userVolume - 0.1);
break; break;
case 'm': case 'm':
trackKeyboardShortcut('M');
audioPlayer.muted = !audioPlayer.muted; audioPlayer.muted = !audioPlayer.muted;
break; break;
case 's': case 's':
trackKeyboardShortcut('S');
document.getElementById('shuffle-btn')?.click(); document.getElementById('shuffle-btn')?.click();
break; break;
case 'r': case 'r':
trackKeyboardShortcut('R');
document.getElementById('repeat-btn')?.click(); document.getElementById('repeat-btn')?.click();
break; break;
case 'q': case 'q':
trackKeyboardShortcut('Q');
document.getElementById('queue-btn')?.click(); document.getElementById('queue-btn')?.click();
break; break;
case '/': case '/':
e.preventDefault(); e.preventDefault();
trackKeyboardShortcut('/');
document.getElementById('search-input')?.focus(); document.getElementById('search-input')?.focus();
break; break;
case 'escape': case 'escape':
trackKeyboardShortcut('Escape');
document.getElementById('search-input')?.blur(); document.getElementById('search-input')?.blur();
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
break; break;
case 'l': case 'l':
trackKeyboardShortcut('L');
document.querySelector('.now-playing-bar .cover')?.click(); document.querySelector('.now-playing-bar .cover')?.click();
break; break;
} }
@ -230,6 +277,9 @@ async function disablePwaForAuthGate() {
} }
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
// Initialize analytics
initAnalytics();
const api = new MusicAPI(apiSettings); const api = new MusicAPI(apiSettings);
const audioPlayer = document.getElementById('audio-player'); const audioPlayer = document.getElementById('audio-player');
@ -313,6 +363,17 @@ document.addEventListener('DOMContentLoaded', async () => {
const { initializeSettings } = await loadSettingsModule(); const { initializeSettings } = await loadSettingsModule();
initializeSettings(scrobbler, player, api, ui); 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); initializePlayerEvents(player, audioPlayer, scrobbler, ui);
initializeTrackInteractions( initializeTrackInteractions(
player, player,
@ -339,6 +400,23 @@ document.addEventListener('DOMContentLoaded', async () => {
const mode = nowPlayingSettings.getMode(); 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') { if (mode === 'lyrics') {
const isActive = sidePanelManager.isActive('lyrics'); const isActive = sidePanelManager.isActive('lyrics');
@ -375,6 +453,7 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => { document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => {
trackCloseFullscreenCover();
if (window.location.hash === '#fullscreen') { if (window.location.hash === '#fullscreen') {
window.history.back(); window.history.back();
} else { } else {
@ -403,6 +482,32 @@ document.addEventListener('DOMContentLoaded', async () => {
sidebarSettings.setCollapsed(isCollapsed); 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', () => { document.getElementById('nav-back')?.addEventListener('click', () => {
window.history.back(); window.history.back();
}); });
@ -622,14 +727,23 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
if (e.target.closest('#create-playlist-btn')) { if (e.target.closest('#create-playlist-btn')) {
trackOpenModal('Create Playlist');
const modal = document.getElementById('playlist-modal'); const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Create Playlist'; document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
document.getElementById('playlist-name-input').value = ''; document.getElementById('playlist-name-input').value = '';
document.getElementById('playlist-cover-input').value = ''; document.getElementById('playlist-cover-input').value = '';
document.getElementById('playlist-description-input').value = ''; document.getElementById('playlist-description-input').value = '';
modal.dataset.editingId = ''; 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('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 // Reset Public Toggle
const publicToggle = document.getElementById('playlist-public-toggle'); const publicToggle = document.getElementById('playlist-public-toggle');
@ -642,6 +756,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
if (e.target.closest('#create-folder-btn')) { if (e.target.closest('#create-folder-btn')) {
trackOpenModal('Create Folder');
const modal = document.getElementById('folder-modal'); const modal = document.getElementById('folder-modal');
document.getElementById('folder-name-input').value = ''; document.getElementById('folder-name-input').value = '';
document.getElementById('folder-cover-input').value = ''; document.getElementById('folder-cover-input').value = '';
@ -655,9 +770,11 @@ document.addEventListener('DOMContentLoaded', async () => {
if (name) { if (name) {
const folder = await db.createFolder(name, cover); const folder = await db.createFolder(name, cover);
trackCreateFolder(folder);
await syncManager.syncUserFolder(folder, 'create'); await syncManager.syncUserFolder(folder, 'create');
ui.renderLibraryPage(); ui.renderLibraryPage();
document.getElementById('folder-modal').classList.remove('active'); 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')) { if (e.target.closest('#playlist-modal-save')) {
const name = document.getElementById('playlist-name-input').value.trim(); let name = document.getElementById('playlist-name-input').value.trim();
const description = document.getElementById('playlist-description-input').value.trim(); let description = document.getElementById('playlist-description-input').value.trim();
const isPublic = document.getElementById('playlist-public-toggle')?.checked; const isPublic = document.getElementById('playlist-public-toggle')?.checked;
if (name) { if (name) {
@ -726,10 +843,100 @@ document.addEventListener('DOMContentLoaded', async () => {
} else { } else {
// Create // Create
const csvFileInput = document.getElementById('csv-file-input'); const csvFileInput = document.getElementById('csv-file-input');
const jspfFileInput = document.getElementById('jspf-file-input');
let tracks = []; 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 // Import from CSV
importSource = 'csv_import';
const file = csvFileInput.files[0]; const file = csvFileInput.files[0];
const progressElement = document.getElementById('csv-import-progress'); const progressElement = document.getElementById('csv-import-progress');
const progressFill = document.getElementById('csv-progress-fill'); 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) // Check for pending tracks (from Add to Playlist -> New Playlist)
const modal = document.getElementById('playlist-modal'); const modal = document.getElementById('playlist-modal');
if (modal._pendingTracks && Array.isArray(modal._pendingTracks)) { if (modal._pendingTracks && Array.isArray(modal._pendingTracks)) {
@ -805,8 +1010,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// Update DB again with isPublic flag // Update DB again with isPublic flag
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
await syncManager.syncUserPlaylist(playlist, 'create'); await syncManager.syncUserPlaylist(playlist, 'create');
trackCreatePlaylist(playlist, importSource);
ui.renderLibraryPage(); ui.renderLibraryPage();
modal.classList.remove('active'); modal.classList.remove('active');
trackCloseModal('Create Playlist');
}); });
} }
} }
@ -844,7 +1051,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
modal.dataset.editingId = playlistId; modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none'; document.getElementById('import-section').style.display = 'none';
modal.classList.add('active'); modal.classList.add('active');
document.getElementById('playlist-name-input').focus(); document.getElementById('playlist-name-input').focus();
} }
@ -885,7 +1092,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
modal.dataset.editingId = playlistId; modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none'; document.getElementById('import-section').style.display = 'none';
modal.classList.add('active'); modal.classList.add('active');
document.getElementById('playlist-name-input').focus(); document.getElementById('playlist-name-input').focus();
} }
@ -1048,7 +1255,7 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('playlist-name-input').value = ''; document.getElementById('playlist-name-input').value = '';
document.getElementById('playlist-cover-input').value = ''; document.getElementById('playlist-cover-input').value = '';
createModal.dataset.editingId = ''; 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 // Pass tracks
createModal._pendingTracks = tracks; createModal._pendingTracks = tracks;
@ -1228,6 +1435,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Local Files Logic lollll // Local Files Logic lollll
if (e.target.closest('#select-local-folder-btn') || e.target.closest('#change-local-folder-btn')) { 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 { try {
const handle = await window.showDirectoryPicker({ const handle = await window.showDirectoryPicker({
id: 'music-folder', id: 'music-folder',
@ -1235,6 +1443,9 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
await db.saveSetting('local_folder_handle', handle); await db.saveSetting('local_folder_handle', handle);
if (isChange) {
trackChangeLocalFolder();
}
const btn = document.getElementById('select-local-folder-btn'); const btn = document.getElementById('select-local-folder-btn');
const btnText = document.getElementById('select-local-folder-text'); const btnText = document.getElementById('select-local-folder-text');
@ -1279,6 +1490,7 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
window.localFilesCache = tracks; window.localFilesCache = tracks;
trackSelectLocalFolder(tracks.length);
ui.renderLibraryPage(); ui.renderLibraryPage();
} catch (err) { } catch (err) {
if (err.name !== 'AbortError') { if (err.name !== 'AbortError') {
@ -1411,10 +1623,14 @@ document.addEventListener('DOMContentLoaded', async () => {
onNeedRefresh() { onNeedRefresh() {
if (pwaUpdateSettings.isAutoUpdateEnabled()) { if (pwaUpdateSettings.isAutoUpdateEnabled()) {
// Auto-update: immediately activate the new service worker // Auto-update: immediately activate the new service worker
trackPwaUpdate();
updateSW(true); updateSW(true);
} else { } else {
// Show notification with Update button and dismiss option // Show notification with Update button and dismiss option
showUpdateNotification(() => updateSW(true)); showUpdateNotification(() => {
trackPwaUpdate();
updateSW(true);
});
} }
}, },
onOfflineReady() { onOfflineReady() {
@ -1538,6 +1754,7 @@ function showUpdateNotification(updateCallback) {
}); });
document.getElementById('dismiss-update-btn').addEventListener('click', () => { document.getElementById('dismiss-update-btn').addEventListener('click', () => {
trackDismissUpdate();
notification.remove(); notification.remove();
}); });
} }
@ -1860,6 +2077,165 @@ async function parseCSV(csvText, api, onProgress) {
return { tracks, missingTracks }; 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) { function showDiscographyDownloadModal(artist, api, quality, lyricsManager, triggerBtn) {
const modal = document.getElementById('discography-download-modal'); const modal = document.getElementById('discography-download-modal');

View file

@ -19,6 +19,45 @@ import { db } from './db.js';
import { syncManager } from './accounts/pocketbase.js'; import { syncManager } from './accounts/pocketbase.js';
import { waveformGenerator } from './waveform.js'; import { waveformGenerator } from './waveform.js';
import { audioContextManager } from './audio-context.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; let currentTrackIdForWaveform = null;
@ -61,6 +100,9 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
audioContextManager.resume(); audioContextManager.resume();
if (player.currentTrack) { if (player.currentTrack) {
// Track play event
trackPlayTrack(player.currentTrack);
// Scrobble // Scrobble
if (scrobbler.isAuthenticated()) { if (scrobbler.isAuthenticated()) {
scrobbler.updateNowPlaying(player.currentTrack); scrobbler.updateNowPlaying(player.currentTrack);
@ -81,6 +123,9 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
}); });
audioPlayer.addEventListener('pause', () => { audioPlayer.addEventListener('pause', () => {
if (player.currentTrack) {
trackPauseTrack(player.currentTrack);
}
playPauseBtn.innerHTML = SVG_PLAY; playPauseBtn.innerHTML = SVG_PLAY;
player.updateMediaSessionPlaybackState(); player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState(); player.updateMediaSessionPositionState();
@ -98,6 +143,9 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
progressFill.style.width = `${(currentTime / duration) * 100}%`; progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime); currentTimeEl.textContent = formatTime(currentTime);
// Track seek milestones
trackSeek(currentTime, duration);
// Log to history after 10 seconds of playback // Log to history after 10 seconds of playback
if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) { if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
historyLoggedTrackId = player.currentTrack.id; historyLoggedTrackId = player.currentTrack.id;
@ -173,17 +221,25 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
}); });
playPauseBtn.addEventListener('click', () => player.handlePlayPause()); playPauseBtn.addEventListener('click', () => player.handlePlayPause());
nextBtn.addEventListener('click', () => player.playNext()); nextBtn.addEventListener('click', () => {
prevBtn.addEventListener('click', () => player.playPrev()); trackSkipTrack(player.currentTrack, 'next');
player.playNext();
});
prevBtn.addEventListener('click', () => {
trackSkipTrack(player.currentTrack, 'previous');
player.playPrev();
});
shuffleBtn.addEventListener('click', () => { shuffleBtn.addEventListener('click', () => {
player.toggleShuffle(); player.toggleShuffle();
trackToggleShuffle(player.shuffleActive);
shuffleBtn.classList.toggle('active', player.shuffleActive); shuffleBtn.classList.toggle('active', player.shuffleActive);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) window.renderQueueFunction();
}); });
repeatBtn.addEventListener('click', () => { repeatBtn.addEventListener('click', () => {
const mode = player.toggleRepeat(); 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('active', mode !== REPEAT_MODE.OFF);
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE); repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
repeatBtn.title = repeatBtn.title =
@ -195,6 +251,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
sleepTimerBtnDesktop.addEventListener('click', () => { sleepTimerBtnDesktop.addEventListener('click', () => {
if (player.isSleepTimerActive()) { if (player.isSleepTimerActive()) {
player.clearSleepTimer(); player.clearSleepTimer();
trackCancelSleepTimer();
showNotification('Sleep timer cancelled'); showNotification('Sleep timer cancelled');
} else { } else {
showSleepTimerModal(player); showSleepTimerModal(player);
@ -207,6 +264,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
sleepTimerBtnMobile.addEventListener('click', () => { sleepTimerBtnMobile.addEventListener('click', () => {
if (player.isSleepTimerActive()) { if (player.isSleepTimerActive()) {
player.clearSleepTimer(); player.clearSleepTimer();
trackCancelSleepTimer();
showNotification('Sleep timer cancelled'); showNotification('Sleep timer cancelled');
} else { } else {
showSleepTimerModal(player); showSleepTimerModal(player);
@ -858,10 +916,12 @@ export async function handleTrackAction(
} }
if (action === 'add-to-queue') { if (action === 'add-to-queue') {
trackAddToQueue(item, 'end');
player.addToQueue(item); player.addToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Added to queue: ${item.title}`); showNotification(`Added to queue: ${item.title}`);
} else if (action === 'play-next') { } else if (action === 'play-next') {
trackPlayNext(item);
player.addNextToQueue(item); player.addNextToQueue(item);
if (window.renderQueueFunction) window.renderQueueFunction(); if (window.renderQueueFunction) window.renderQueueFunction();
showNotification(`Playing next: ${item.title}`); showNotification(`Playing next: ${item.title}`);
@ -870,17 +930,32 @@ export async function handleTrackAction(
player.playAtIndex(0); player.playAtIndex(0);
showNotification(`Playing track: ${item.title}`); showNotification(`Playing track: ${item.title}`);
} else if (action === 'start-mix') { } else if (action === 'start-mix') {
trackStartMix(type, item);
if (item.mixes?.TRACK_MIX) { if (item.mixes?.TRACK_MIX) {
navigate(`/mix/${item.mixes.TRACK_MIX}`); navigate(`/mix/${item.mixes.TRACK_MIX}`);
} else { } else {
showNotification('No mix available for this track'); showNotification('No mix available for this track');
} }
} else if (action === 'download') { } else if (action === 'download') {
trackDownloadTrack(item, downloadQualitySettings.getQuality());
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager); await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
} else if (action === 'toggle-like') { } else if (action === 'toggle-like') {
const added = await db.toggleFavorite(type, item); const added = await db.toggleFavorite(type, item);
syncManager.syncLibraryItem(type, item, added); 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 (added && type === 'track' && scrobbler) {
if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) { if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
scrobbler.loveTrack(item); scrobbler.loveTrack(item);
@ -1088,6 +1163,7 @@ export async function handleTrackAction(
? `${window.location.origin}${storedHref}` ? `${window.location.origin}${storedHref}`
: `${window.location.origin}/track/${item.id || item.uuid}`; : `${window.location.origin}/track/${item.id || item.uuid}`;
trackCopyLink(type, item.id || item.uuid);
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
showNotification('Link copied to clipboard!'); showNotification('Link copied to clipboard!');
}); });
@ -1099,6 +1175,7 @@ export async function handleTrackAction(
? `${window.location.origin}${storedHref}` ? `${window.location.origin}${storedHref}`
: `${window.location.origin}/track/${item.id || item.uuid}`; : `${window.location.origin}/track/${item.id || item.uuid}`;
trackOpenInNewTab(type, item.id || item.uuid);
window.open(url, '_blank'); window.open(url, '_blank');
} else if (action === 'track-info') { } else if (action === 'track-info') {
// Show detailed track info modal // Show detailed track info modal
@ -1268,9 +1345,11 @@ export async function handleTrackAction(
const { contentBlockingSettings } = await import('./storage.js'); const { contentBlockingSettings } = await import('./storage.js');
if (contentBlockingSettings.isTrackBlocked(item.id)) { if (contentBlockingSettings.isTrackBlocked(item.id)) {
contentBlockingSettings.unblockTrack(item.id); contentBlockingSettings.unblockTrack(item.id);
trackUnblockTrack(item);
showNotification(`Unblocked track: ${item.title}`); showNotification(`Unblocked track: ${item.title}`);
} else { } else {
contentBlockingSettings.blockTrack(item); contentBlockingSettings.blockTrack(item);
trackBlockTrack(item);
showNotification(`Blocked track: ${item.title}`); showNotification(`Blocked track: ${item.title}`);
} }
} else if (action === 'block-album') { } else if (action === 'block-album') {
@ -1284,15 +1363,15 @@ export async function handleTrackAction(
return; return;
} }
const albumObj = { id: albumId, title: albumTitle, artist: albumArtist };
if (contentBlockingSettings.isAlbumBlocked(albumId)) { if (contentBlockingSettings.isAlbumBlocked(albumId)) {
contentBlockingSettings.unblockAlbum(albumId); contentBlockingSettings.unblockAlbum(albumId);
trackUnblockAlbum(albumObj);
showNotification(`Unblocked album: ${albumTitle || 'Unknown Album'}`); showNotification(`Unblocked album: ${albumTitle || 'Unknown Album'}`);
} else { } else {
contentBlockingSettings.blockAlbum({ contentBlockingSettings.blockAlbum(albumObj);
id: albumId, trackBlockAlbum(albumObj);
title: albumTitle,
artist: albumArtist,
});
showNotification(`Blocked album: ${albumTitle || 'Unknown Album'}`); showNotification(`Blocked album: ${albumTitle || 'Unknown Album'}`);
} }
} else if (action === 'block-artist') { } else if (action === 'block-artist') {
@ -1305,14 +1384,15 @@ export async function handleTrackAction(
return; return;
} }
const artistObj = { id: artistId, name: artistName };
if (contentBlockingSettings.isArtistBlocked(artistId)) { if (contentBlockingSettings.isArtistBlocked(artistId)) {
contentBlockingSettings.unblockArtist(artistId); contentBlockingSettings.unblockArtist(artistId);
trackUnblockArtist(artistObj);
showNotification(`Unblocked artist: ${artistName || 'Unknown Artist'}`); showNotification(`Unblocked artist: ${artistName || 'Unknown Artist'}`);
} else { } else {
contentBlockingSettings.blockArtist({ contentBlockingSettings.blockArtist(artistObj);
id: artistId, trackBlockArtist(artistObj);
name: artistName,
});
showNotification(`Blocked artist: ${artistName || 'Unknown Artist'}`); showNotification(`Blocked artist: ${artistName || 'Unknown Artist'}`);
} }
} }
@ -1630,6 +1710,8 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const track = contextMenu._contextTrack || contextTrack; const track = contextMenu._contextTrack || contextTrack;
const type = contextMenu._contextType || 'track'; const type = contextMenu._contextType || 'track';
if (action && track) { if (action && track) {
// Track context menu action
trackContextMenuAction(action, type, track);
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler); await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler);
} }
contextMenu.style.display = 'none'; contextMenu.style.display = 'none';
@ -1784,6 +1866,7 @@ function showSleepTimerModal(player) {
if (minutes) { if (minutes) {
player.setSleepTimer(minutes); player.setSleepTimer(minutes);
trackSetSleepTimer(minutes);
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`); showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
closeModal(); closeModal();
} }

View file

@ -1,3 +1,5 @@
import { trackCloseSidePanel, trackCloseQueue, trackCloseLyrics } from './analytics.js';
export class SidePanelManager { export class SidePanelManager {
constructor() { constructor() {
this.panel = document.getElementById('side-panel'); this.panel = document.getElementById('side-panel');
@ -30,6 +32,20 @@ export class SidePanelManager {
} }
close() { 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.panel.classList.remove('active');
this.currentView = null; this.currentView = null;
// Optionally clear content after transition // Optionally clear content after transition

View file

@ -16,6 +16,7 @@ import { downloadQualitySettings, contentBlockingSettings } from './storage.js';
import { db } from './db.js'; import { db } from './db.js';
import { syncManager } from './accounts/pocketbase.js'; import { syncManager } from './accounts/pocketbase.js';
import { showNotification, downloadTracks } from './downloads.js'; import { showNotification, downloadTracks } from './downloads.js';
import { trackSearchTabChange, trackOpenQueue, trackCloseQueue, trackChangeSort } from './analytics.js';
export function initializeUIInteractions(player, api, ui) { export function initializeUIInteractions(player, api, ui) {
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
@ -386,6 +387,7 @@ export function initializeUIInteractions(player, api, ui) {
}; };
const openQueuePanel = () => { const openQueuePanel = () => {
trackOpenQueue();
sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent); sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent);
}; };
@ -439,6 +441,9 @@ export function initializeUIInteractions(player, api, ui) {
const page = tab.closest('.page'); const page = tab.closest('.page');
if (!page) return; if (!page) return;
// Track tab change
trackSearchTabChange(tab.dataset.tab);
page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active')); page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active'));
page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active')); page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active'));

View file

@ -45,6 +45,13 @@ import {
createProjectCardHTML, createProjectCardHTML,
createTrackFromSong, createTrackFromSong,
} from './tracker.js'; } from './tracker.js';
import {
trackSearch,
trackSearchTabChange,
trackClearSearchHistory,
trackClickSearchHistory,
trackChangeSort,
} from './analytics.js';
fontSettings.applyFont(); fontSettings.applyFont();
fontSettings.applyFontSize(); fontSettings.applyFontSize();
@ -2012,6 +2019,10 @@ export class UIRenderer {
finalAlbums = Array.from(albumMap.values()); 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) { if (finalTracks.length) {
this.renderListWithTracks(tracksContainer, finalTracks, true); this.renderListWithTracks(tracksContainer, finalTracks, true);
} else { } else {
@ -3275,6 +3286,7 @@ export class UIRenderer {
const handleSort = (ev) => { const handleSort = (ev) => {
const li = ev.target.closest('li'); const li = ev.target.closest('li');
if (li && li.dataset.sort) { if (li && li.dataset.sort) {
trackChangeSort(li.dataset.sort);
onSort(li.dataset.sort); onSort(li.dataset.sort);
closeMenu(); closeMenu();
} }

View file

@ -2,10 +2,7 @@
Sorted by ease of implementation (easiest to hardest): 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 - [ ] 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 - [ ] 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 [ ] SoundCloud support: Integrate SoundCloud through SoundCloak
[ ] Qobuz support: Integrate Qobuz through Qobuz-DL