JSPF playlist imports + plausible
This commit is contained in:
parent
13b350edc8
commit
c23f858412
8 changed files with 1275 additions and 43 deletions
116
index.html
116
index.html
|
|
@ -45,6 +45,22 @@
|
|||
rel="stylesheet"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
|
@ -410,7 +426,7 @@
|
|||
></textarea>
|
||||
<br />
|
||||
<div
|
||||
id="csv-import-section"
|
||||
id="import-section"
|
||||
style="
|
||||
display: none;
|
||||
margin: 1rem 0;
|
||||
|
|
@ -420,25 +436,87 @@
|
|||
background: var(--background-secondary);
|
||||
"
|
||||
>
|
||||
<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
|
||||
<div
|
||||
class="import-tabs"
|
||||
style="
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<input
|
||||
type="file"
|
||||
id="csv-file-input"
|
||||
class="btn-secondary"
|
||||
accept=".csv"
|
||||
style="width: 100%; margin-bottom: 0.5rem"
|
||||
/>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="import-tab"
|
||||
data-import-type="jspf"
|
||||
style="
|
||||
background: transparent;
|
||||
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">
|
||||
<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.
|
||||
|
|
|
|||
665
js/analytics.js
Normal file
665
js/analytics.js
Normal 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
396
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');
|
||||
|
||||
|
|
|
|||
105
js/events.js
105
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
12
js/ui.js
12
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();
|
||||
}
|
||||
|
|
|
|||
3
todo.md
3
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue