kv-music/js/app.js

3289 lines
139 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//js/app.js
import { isIos, isSafari } from './platform-detection.js';
import { MusicAPI } from './music-api.js';
import {
apiSettings,
themeManager,
nowPlayingSettings,
fullscreenCoverClickSettings,
downloadQualitySettings,
sidebarSettings,
pwaUpdateSettings,
modalSettings,
keyboardShortcuts,
} from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import { MultiScrobbler } from './multi-scrobbler.js';
import { LyricsManager, openLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
import { createRouter, updateTabTitle, navigate } from './router.js';
import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js';
import { initializeUIInteractions } from './ui-interactions.js';
import { debounce, getShareUrl } from './utils.js';
import { sidePanelManager } from './side-panel.js';
import { db } from './db.js';
import { showNotification } from './downloads.js';
import { syncManager } from './accounts/pocketbase.js';
import { authManager } from './accounts/auth.js';
import { registerSW } from 'virtual:pwa-register';
import { openEditProfile } from './profile.js';
import { ThemeStore } from './themeStore.js';
import './commandPalette.js';
import { initTracker } from './tracker.js';
import {
initAnalytics,
trackSidebarNavigation,
trackCreatePlaylist,
trackCreateFolder,
trackImportJSPF,
trackImportCSV,
trackImportXSPF,
trackImportXML,
trackImportM3U,
trackSelectLocalFolder,
trackChangeLocalFolder,
trackOpenModal,
trackCloseModal,
trackKeyboardShortcut,
trackPwaUpdate,
trackDismissUpdate,
trackOpenFullscreenCover,
trackCloseFullscreenCover,
trackOpenLyrics,
trackCloseLyrics,
} from './analytics.js';
import {
parseCSV,
parseJSPF,
parseXSPF,
parseXML,
parseM3U,
parseDynamicCSV,
importToLibrary,
} from './playlist-importer.js';
import { modernSettings } from './ModernSettings.js';
import {
SVG_OFFLINE,
SVG_RIGHT_ARROW,
SVG_LEFT_ARROW,
SVG_ANIMATE_SPIN,
SVG_PLAY,
SVG_CLOSE,
SVG_RESET,
} from './icons.js';
// Capture real iOS state before spoofing (needed for background audio)
if (typeof window !== 'undefined') {
const _ua = navigator.userAgent.toLowerCase();
// Spoof User-Agent to bypass Google's embedded browser check
Object.defineProperty(navigator, 'userAgent', {
get: function () {
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
},
});
// analytics
const plausibleScript = document.createElement('script');
plausibleScript.async = true;
plausibleScript.src = 'https://plausible.canine.tools/js/pa-dCMvQpiD1-AJmi8o3xviO.js';
document.head.appendChild(plausibleScript);
window.plausible =
window.plausible ||
function () {
(window.plausible.q = window.plausible.q || []).push(arguments);
};
window.plausible.init =
window.plausible.init ||
function (i) {
window.plausible.o = i || {};
};
window.plausible.init();
}
// Lazy-loaded modules
let settingsModule = null;
let downloadsModule = null;
let metadataModule = null;
export const managers = {};
async function loadSettingsModule() {
if (!settingsModule) {
settingsModule = await import('./settings.js');
}
return settingsModule;
}
async function loadDownloadsModule() {
if (!downloadsModule) {
downloadsModule = await import('./downloads.js');
}
return downloadsModule;
}
async function loadMetadataModule() {
if (!metadataModule) {
metadataModule = await import('./metadata.js');
}
return metadataModule;
}
function initializeCasting(audioPlayer, castBtn) {
if (!castBtn) return;
if ('remote' in audioPlayer) {
audioPlayer.remote
.watchAvailability((available) => {
if (available) {
castBtn.style.display = 'flex';
castBtn.classList.add('available');
}
})
.catch((err) => {
console.log('Remote playback not available:', err);
if (window.innerWidth > 768) {
castBtn.style.display = 'flex';
}
});
castBtn.addEventListener('click', () => {
if (!audioPlayer.src) {
alert('Please play a track first to enable casting.');
return;
}
audioPlayer.remote.prompt().catch((err) => {
if (err.name === 'NotAllowedError') return;
if (err.name === 'NotFoundError') {
alert('No remote playback devices (Chromecast/AirPlay) were found on your network.');
return;
}
console.log('Cast prompt error:', err);
});
});
audioPlayer.addEventListener('playing', () => {
if (audioPlayer.remote && audioPlayer.remote.state === 'connected') {
castBtn.classList.add('connected');
}
});
audioPlayer.addEventListener('pause', () => {
if (audioPlayer.remote && audioPlayer.remote.state === 'disconnected') {
castBtn.classList.remove('connected');
}
});
} else if (audioPlayer.webkitShowPlaybackTargetPicker) {
castBtn.style.display = 'flex';
castBtn.classList.add('available');
castBtn.addEventListener('click', () => {
audioPlayer.webkitShowPlaybackTargetPicker();
});
audioPlayer.addEventListener('webkitplaybacktargetavailabilitychanged', (e) => {
if (e.availability === 'available') {
castBtn.classList.add('available');
}
});
audioPlayer.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', () => {
if (audioPlayer.webkitCurrentPlaybackTargetIsWireless) {
castBtn.classList.add('connected');
} else {
castBtn.classList.remove('connected');
}
});
} else if (window.innerWidth > 768) {
castBtn.style.display = 'flex';
castBtn.addEventListener('click', () => {
alert('Casting is not supported in this browser. Try Chrome for Chromecast or Safari for AirPlay.');
});
}
}
function initializeKeyboardShortcuts(player, _audioPlayer) {
const keyActionMap = {
playPause: () => {
trackKeyboardShortcut('Space');
player.handlePlayPause();
},
seekForward: () => {
trackKeyboardShortcut('Right');
player.seekForward(10);
},
seekBackward: () => {
trackKeyboardShortcut('Left');
player.seekBackward(10);
},
nextTrack: () => {
trackKeyboardShortcut('Shift+Right');
player.playNext();
},
previousTrack: () => {
trackKeyboardShortcut('Shift+Left');
player.playPrev();
},
volumeUp: () => {
trackKeyboardShortcut('Up');
player.setVolume(player.userVolume + 0.1);
},
volumeDown: () => {
trackKeyboardShortcut('Down');
player.setVolume(player.userVolume - 0.1);
},
mute: () => {
trackKeyboardShortcut('M');
const el = player.activeElement;
el.muted = !el.muted;
},
shuffle: () => {
trackKeyboardShortcut('S');
document.getElementById('shuffle-btn')?.click();
},
repeat: () => {
trackKeyboardShortcut('R');
document.getElementById('repeat-btn')?.click();
},
queue: () => {
trackKeyboardShortcut('Q');
document.getElementById('queue-btn')?.click();
},
lyrics: () => {
trackKeyboardShortcut('L');
document.querySelector('.now-playing-bar .cover')?.click();
},
search: () => {
trackKeyboardShortcut('/');
document.getElementById('search-input')?.focus();
},
escape: () => {
trackKeyboardShortcut('Escape');
document.getElementById('search-input')?.blur();
sidePanelManager.close();
clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
},
visualizerNext: () => {
trackKeyboardShortcut('VisualizerNext');
const ui = window.monochromeUi;
if (ui?.visualizer?.presets?.['butterchurn']) {
ui.visualizer.presets['butterchurn'].nextPreset();
}
},
visualizerPrev: () => {
trackKeyboardShortcut('VisualizerPrev');
const ui = window.monochromeUi;
if (ui?.visualizer?.presets?.['butterchurn']) {
ui.visualizer.presets['butterchurn'].prevPreset();
}
},
visualizerCycle: () => {
trackKeyboardShortcut('VisualizerCycle');
const ui = window.monochromeUi;
if (ui?.visualizer?.presets?.['butterchurn']) {
ui.visualizer.presets['butterchurn'].toggleCycle();
}
},
};
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, [contenteditable="true"]')) return;
const shortcuts = keyboardShortcuts.getShortcuts();
const pressedKey = e.key.toLowerCase();
const hasShift = e.shiftKey;
const hasCtrl = e.ctrlKey || e.metaKey;
const hasAlt = e.altKey;
for (const [action, shortcut] of Object.entries(shortcuts)) {
if (!shortcut?.key) continue;
const shortcutKey = shortcut.key.toLowerCase();
const matches =
pressedKey === shortcutKey &&
shortcut.shift === hasShift &&
shortcut.ctrl === hasCtrl &&
shortcut.alt === hasAlt;
if (matches) {
e.preventDefault();
const actionFn = keyActionMap[action];
if (actionFn) {
actionFn();
}
return;
}
}
});
}
function showOfflineNotification() {
const notification = document.createElement('div');
notification.className = 'offline-notification';
notification.innerHTML = `
${SVG_OFFLINE(20)}
<span>You are offline. Some features may not work.</span>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slide-out 0.3s ease forwards';
setTimeout(() => notification.remove(), 300);
}, 5000);
}
function hideOfflineNotification() {
const notification = document.querySelector('.offline-notification');
if (notification) {
notification.style.animation = 'slide-out 0.3s ease forwards';
setTimeout(() => notification.remove(), 300);
}
}
async function disablePwaForAuthGate() {
if (!('serviceWorker' in navigator)) return;
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
} catch (error) {
console.warn('Failed to unregister service workers:', error);
}
if ('caches' in window) {
try {
const cacheKeys = await caches.keys();
await Promise.all(cacheKeys.map((key) => caches.delete(key)));
} catch (error) {
console.warn('Failed to clear caches:', error);
}
}
}
async function uploadCoverImage(file) {
try {
const response = await fetch(`https://worker.uploads.monochrome.qzz.io/${file.name}`, {
method: 'PUT',
headers: {
'x-api-key': 'if_youre_reading_this_fuck_off',
'Content-Type': file.type || 'application/octet-stream',
},
body: file,
});
if (!response.ok) {
if (response.status === 413) throw new Error('File exceeds 10MB');
throw new Error(`Upload failed: ${response.status}`);
}
return `https://images.monochrome.qzz.io/${await response.text()}`;
} catch (error) {
console.error('Cover upload error:', error);
throw error;
}
}
document.addEventListener('DOMContentLoaded', async () => {
await modernSettings.waitPending();
// Initialize analytics
initAnalytics();
new ThemeStore();
await MusicAPI.initialize(apiSettings);
const audioPlayer = document.getElementById('audio-player');
// i love ios and macos!!!! webkit fucking SUCKS BULLSHIT sorry ios/macos heads yall getting lossless only playback
// Use isIos from platform-detection (set before UA spoof in index.html) so detection works on real iOS.
if (isIos || isSafari) {
const qualitySelect = document.getElementById('streaming-quality-setting');
const downloadQualitySelect = document.getElementById('download-quality-setting');
const removeHiRes = (select) => {
if (!select) return;
const option = select.querySelector('option[value="HI_RES_LOSSLESS"]');
if (option) option.remove();
};
removeHiRes(qualitySelect);
if (isIos) {
document.querySelector('#hi-res-download-warning').style.display = '';
}
const currentQualitySetting = localStorage.getItem('playback-quality');
if (!currentQualitySetting || currentQualitySetting === 'HI_RES_LOSSLESS') {
localStorage.setItem('playback-quality', 'LOSSLESS');
}
}
const currentQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS';
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
// Initialize tracker
initTracker();
// Linux Media Keys Fix
if (window.NL_MODE) {
import('./desktop/neutralino-bridge.js').then(({ events }) => {
events.on('mediaNext', () => Player.instance.playNext());
events.on('mediaPrevious', () => Player.instance.playPrev());
events.on('mediaPlayPause', () => Player.instance.handlePlayPause());
events.on('mediaStop', () => {
const el = Player.instance.activeElement;
el.pause();
el.currentTime = 0;
});
console.log('Media keys initialized via bridge');
});
}
// Initialize desktop features if in Neutralino mode
if (
typeof window !== 'undefined' &&
(window.NL_MODE ||
window.location.search.includes('mode=neutralino') ||
window.location.search.includes('nl_port='))
) {
window.NL_MODE = true;
try {
const desktopModule = await import('./desktop/desktop.js');
await desktopModule.initDesktop(Player.instance);
import('./desktop/neutralino-bridge.js').then(({ updater }) => {
setTimeout(async () => {
try {
// my worker should detect a users OS and serve the right ver
const update = await updater.checkForUpdates('https://update.samidy.xyz/update.json');
if (update && update.available) {
const modal = document.getElementById('desktop-update-modal');
const notes = document.getElementById('desktop-update-notes');
const confirmBtn = document.getElementById('desktop-update-confirm');
const cancelBtn = document.getElementById('desktop-update-cancel');
if (modal) {
notes.innerHTML = update.notes || 'Bug fixes and improvements.';
modal.classList.add('active');
confirmBtn.onclick = async () => {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Updating...';
try {
await updater.install();
} catch (err) {
console.error(err);
confirmBtn.textContent = 'Failed';
setTimeout(() => {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Update Now';
}, 2000);
}
};
cancelBtn.onclick = () => modal.classList.remove('active');
}
}
} catch (e) {
console.warn('Failed to check for desktop updates:', e);
}
}, 3000);
});
} catch (err) {
console.error('Failed to load desktop module:', err);
}
}
const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);
const ui = new UIRenderer(MusicAPI.instance, Player.instance);
window.monochromeUi = ui;
/**
* Scans the configured local media folder and refreshes `window.localFilesCache`.
* Called by the folder-select button handler and by downloads.js after a
* successful write to the local media folder.
*
* @param {boolean} [onlyIfAlreadyScanned=false] When true, skips the scan if
* `window.localFilesCache` has never been populated (i.e. the user hasn't
* visited the local tab yet).
*/
async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) {
// Skip the scan if the user has never visited the local tab they'll
// get a fresh scan when they navigate there for the first time.
if (onlyIfAlreadyScanned && !window.localFilesCache) return;
// Prevent concurrent scans.
if (window.localFilesScanInProgress) return;
window.localFilesScanInProgress = true;
try {
const handle = await db.getSetting('local_folder_handle');
if (!handle) return;
const isNeutralino =
window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino'));
const tracks = (window.localFilesCache = []);
let idCounter = 0;
const { readTrackMetadata } = await loadMetadataModule();
if (isNeutralino) {
async function scanNeu(dirPath) {
const entries = await window.Neutralino.filesystem.readDirectory(dirPath);
for (const entry of entries) {
if (entry.entry === '.' || entry.entry === '..') continue;
const fullPath = `${dirPath}/${entry.entry}`;
if (entry.type === 'FILE') {
const name = entry.entry.toLowerCase();
if (
name.endsWith('.flac') ||
name.endsWith('.mp3') ||
name.endsWith('.m4a') ||
name.endsWith('.wav') ||
name.endsWith('.ogg')
) {
try {
const buffer = await window.Neutralino.filesystem.readBinaryFile(fullPath);
const stats = await window.Neutralino.filesystem.getStats(fullPath);
const file = new File([buffer], entry.entry, { lastModified: stats.mtime });
const metadata = await readTrackMetadata(file);
metadata.id = `local-${idCounter++}-${entry.entry}`;
tracks.push(metadata);
window.monochromeUi?.renderLocalFiles(
document.getElementById('library-local-container')
);
} catch (e) {
console.error('Failed to read file:', fullPath, e);
}
}
} else if (entry.type === 'DIRECTORY') {
await scanNeu(fullPath);
}
}
}
await scanNeu(handle.path);
} else {
// Request read permission before iterating. When the browser has
// already granted it (e.g. within the same session or via a
// persistent grant) this succeeds without a user gesture.
if (typeof handle.requestPermission === 'function') {
const permission = await handle.requestPermission({ mode: 'read' });
if (permission !== 'granted') return;
}
async function scanBrowser(dirHandle) {
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
const name = entry.name.toLowerCase();
if (
name.endsWith('.flac') ||
name.endsWith('.mp3') ||
name.endsWith('.m4a') ||
name.endsWith('.wav') ||
name.endsWith('.ogg')
) {
const file = await entry.getFile();
const metadata = await readTrackMetadata(file);
metadata.id = `local-${idCounter++}-${file.name}`;
tracks.push(metadata);
window.monochromeUi?.renderLocalFiles(
document.getElementById('library-local-container')
);
}
} else if (entry.kind === 'directory') {
await scanBrowser(entry);
}
}
}
await scanBrowser(handle);
}
tracks.sort((a, b) => (a.artist.name || '').localeCompare(b.artist.name || ''));
// Update only the local-files section without navigating to the library page.
window.monochromeUi?.renderLocalFiles(document.getElementById('library-local-container'));
} finally {
window.localFilesScanInProgress = false;
}
return window.localFilesCache;
}
/**
* Called by downloads.js (via window) after a successful write to the local
* media folder so the track appears in Library > Local without the user
* having to manually re-scan.
*
* When called with a `blob` and `filename` (single-track download case) it
* performs a cheap partial update — reading metadata only from that one file
* and inserting it into the existing cache — so the full folder does not need
* to be re-walked. When called with no arguments (bulk download case, or when
* `localFilesCache` has never been populated) it falls back to a full rescan.
*/
window.refreshLocalMediaFolder = async (blob = null, filename = null) => {
if (blob && filename) {
try {
/** @type {import("./metadata.js")} */
const { readTrackMetadata } = await loadMetadataModule();
const baseName = filename.split('/').pop();
const metadata = await readTrackMetadata(new Uint8Array(await blob.arrayBuffer()), {
filename: baseName,
});
const existing = window.localFilesCache || [];
metadata.id = `local-${existing.length}-${baseName}`;
window.localFilesCache = [...existing, metadata].sort((a, b) =>
(a.artist.name || '').localeCompare(b.artist.name || '')
);
window.monochromeUi?.renderLocalFiles(document.getElementById('library-local-container'));
} catch {
// Fall back to a full rescan if metadata extraction fails.
await scanLocalMediaFolder(true);
}
} else {
await scanLocalMediaFolder(!!window.localFilesCache);
}
};
// Kick off a background scan of the saved local media folder on startup so
// that the Library > Local tab is populated without requiring the user to
// manually press "Load [folder]" every session. The function internally
// checks for a saved handle and (in browser mode) requests read permission,
// so this is a silent no-op when no folder is configured or permission is not
// yet granted.
scanLocalMediaFolder();
const scrobbler = new MultiScrobbler();
window.monochromeScrobbler = scrobbler;
const lyricsManager = new LyricsManager(MusicAPI.instance);
ui.lyricsManager = lyricsManager;
managers.lyricsManager = lyricsManager;
// Check browser support for local files
const selectLocalBtn = document.getElementById('select-local-folder-btn');
const browserWarning = document.getElementById('local-browser-warning');
if (selectLocalBtn && browserWarning) {
const ua = navigator.userAgent;
const isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua);
const hasFileSystemApi = 'showDirectoryPicker' in window;
const isNeutralino =
window.NL_MODE ||
window.location.search.includes('mode=neutralino') ||
window.location.search.includes('nl_port=');
if (!isNeutralino && (!isChromeOrEdge || !hasFileSystemApi)) {
selectLocalBtn.style.display = 'none';
browserWarning.style.display = 'block';
} else if (isNeutralino) {
selectLocalBtn.style.display = 'flex';
browserWarning.style.display = 'none';
}
}
// Kuroshiro is now loaded on-demand only when needed for Asian text with Romaji mode enabled
const currentTheme = themeManager.getTheme();
themeManager.setTheme(currentTheme);
// Restore sidebar state
sidebarSettings.restoreState();
// Render pinned items
await ui.renderPinnedItems();
// Load settings module and initialize
const { initializeSettings } = await loadSettingsModule();
await initializeSettings(scrobbler, Player.instance, MusicAPI.instance, ui);
// Track sidebar navigation clicks
document.querySelectorAll('.sidebar-nav a').forEach((link) => {
link.addEventListener('click', () => {
const href = link.getAttribute('href');
if (href && !href.startsWith('http')) {
const item = link.querySelector('span')?.textContent || href;
trackSidebarNavigation(item);
}
});
});
initializePlayerEvents(Player.instance, audioPlayer, scrobbler, ui);
initializeTrackInteractions(
Player.instance,
MusicAPI.instance,
document.querySelector('.main-content'),
document.getElementById('context-menu'),
lyricsManager,
ui,
scrobbler
);
initializeUIInteractions(Player.instance, MusicAPI.instance, ui);
initializeKeyboardShortcuts(Player.instance, audioPlayer);
// Restore UI state for the current track (like button, theme)
if (Player.instance.currentTrack) {
ui.setCurrentTrack(Player.instance.currentTrack);
}
document.querySelector('.now-playing-bar').addEventListener('click', async (e) => {
if (!e.target.closest('.cover')) return;
if (!Player.instance.currentTrack) {
alert('No track is currently playing');
return;
}
const mode = nowPlayingSettings.getMode();
if (mode === 'lyrics') {
const isActive = sidePanelManager.isActive('lyrics');
if (isActive) {
trackCloseLyrics(Player.instance.currentTrack);
} else {
trackOpenLyrics(Player.instance.currentTrack);
}
} else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && overlay.style.display === 'flex') {
trackCloseFullscreenCover();
} else {
trackOpenFullscreenCover(Player.instance.currentTrack);
}
}
if (mode === 'lyrics') {
const isActive = sidePanelManager.isActive('lyrics');
if (isActive) {
sidePanelManager.close();
clearLyricsPanelSync(Player.instance.activeElement, sidePanelManager.panel);
} else {
openLyricsPanel(Player.instance.currentTrack, Player.instance.activeElement, lyricsManager);
}
} else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && overlay.style.display === 'flex') {
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
ui.closeFullscreenCover();
}
} else {
const nextTrack = Player.instance.getNextTrack();
ui.showFullscreenCover(
Player.instance.currentTrack,
nextTrack,
lyricsManager,
Player.instance.activeElement
);
}
} else {
// Default to 'album' mode - navigate to album
if (Player.instance.currentTrack.album?.id) {
navigate(`/album/${Player.instance.currentTrack.album.id}`);
}
}
});
// Toggle Share Button visibility on switch change
document.getElementById('playlist-public-toggle')?.addEventListener('change', (e) => {
const shareBtn = document.getElementById('playlist-share-btn');
if (shareBtn) shareBtn.style.display = e.target.checked ? 'flex' : 'none';
});
document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => {
trackCloseFullscreenCover();
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
ui.closeFullscreenCover();
}
});
document.getElementById('fullscreen-cover-overlay')?.addEventListener('click', (e) => {
const coverImage = document.getElementById('fullscreen-cover-image');
if (!coverImage) return;
const isOnCoverImage = e.target.closest('#fullscreen-cover-image') || e.target.id === 'fullscreen-cover-image';
if (!isOnCoverImage) return;
const action = fullscreenCoverClickSettings.getAction();
const overlay = document.getElementById('fullscreen-cover-overlay');
const playerInstance = Player.instance;
switch (action) {
case 'exit':
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
ui.closeFullscreenCover();
}
break;
case 'hide-ui':
if (overlay) {
const isCurrentlyHidden = overlay.classList.contains('ui-hidden');
if (isCurrentlyHidden) {
overlay.classList.remove('ui-hidden');
const toggleBtn = document.getElementById('toggle-ui-btn');
if (toggleBtn) {
toggleBtn.classList.remove('active');
toggleBtn.classList.add('visible');
toggleBtn.title = 'Hide UI';
}
} else {
overlay.classList.add('ui-hidden');
const toggleBtn = document.getElementById('toggle-ui-btn');
if (toggleBtn) {
toggleBtn.classList.add('active');
toggleBtn.classList.remove('visible');
toggleBtn.title = 'Show UI';
}
}
if (ui && typeof ui.setupUIToggleButton === 'function') {
if (ui.uiToggleCleanup) {
ui.uiToggleCleanup();
}
ui.setupUIToggleButton(overlay);
}
}
break;
case 'pause-resume':
if (playerInstance) playerInstance.handlePlayPause();
break;
case 'next':
if (playerInstance) playerInstance.playNext();
break;
case 'previous':
if (playerInstance) playerInstance.playPrev();
break;
case 'nothing':
break;
default:
if (window.location.hash === '#fullscreen') {
window.history.back();
} else {
ui.closeFullscreenCover();
}
}
});
document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
document.body.classList.toggle('sidebar-collapsed');
const isCollapsed = document.body.classList.contains('sidebar-collapsed');
const toggleBtn = document.getElementById('sidebar-toggle');
if (toggleBtn) {
toggleBtn.innerHTML = isCollapsed ? SVG_RIGHT_ARROW(20) : SVG_LEFT_ARROW(20);
}
// Save sidebar state to localStorage
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';
document.getElementById('xspf-import-panel').style.display = importType === 'xspf' ? 'block' : 'none';
document.getElementById('xml-import-panel').style.display = importType === 'xml' ? 'block' : 'none';
document.getElementById('m3u-import-panel').style.display = importType === 'm3u' ? 'block' : 'none';
// Clear all file inputs except the active one
document.getElementById('csv-file-input').value =
importType === 'csv' ? document.getElementById('csv-file-input').value : '';
document.getElementById('jspf-file-input').value =
importType === 'jspf' ? document.getElementById('jspf-file-input').value : '';
document.getElementById('xspf-file-input').value =
importType === 'xspf' ? document.getElementById('xspf-file-input').value : '';
document.getElementById('xml-file-input').value =
importType === 'xml' ? document.getElementById('xml-file-input').value : '';
document.getElementById('m3u-file-input').value =
importType === 'm3u' ? document.getElementById('m3u-file-input').value : '';
});
});
const spotifyBtn = document.getElementById('csv-spotify-btn');
const appleBtn = document.getElementById('csv-apple-btn');
const ytmBtn = document.getElementById('csv-ytm-btn');
const spotifyGuide = document.getElementById('csv-spotify-guide');
const appleGuide = document.getElementById('csv-apple-guide');
const ytmGuide = document.getElementById('csv-ytm-guide');
const inputContainer = document.getElementById('csv-input-container');
if (spotifyBtn && appleBtn && ytmBtn) {
spotifyBtn.addEventListener('click', () => {
spotifyBtn.classList.remove('btn-secondary');
spotifyBtn.classList.add('btn-primary');
spotifyBtn.style.opacity = '1';
appleBtn.classList.remove('btn-primary');
appleBtn.classList.add('btn-secondary');
appleBtn.style.opacity = '0.7';
ytmBtn.classList.remove('btn-primary');
ytmBtn.classList.add('btn-secondary');
ytmBtn.style.opacity = '0.7';
spotifyGuide.style.display = 'block';
appleGuide.style.display = 'none';
ytmGuide.style.display = 'none';
inputContainer.style.display = 'block';
});
appleBtn.addEventListener('click', () => {
appleBtn.classList.remove('btn-secondary');
appleBtn.classList.add('btn-primary');
appleBtn.style.opacity = '1';
spotifyBtn.classList.remove('btn-primary');
spotifyBtn.classList.add('btn-secondary');
spotifyBtn.style.opacity = '0.7';
ytmBtn.classList.remove('btn-primary');
ytmBtn.classList.add('btn-secondary');
ytmBtn.style.opacity = '0.7';
appleGuide.style.display = 'block';
spotifyGuide.style.display = 'none';
ytmGuide.style.display = 'none';
inputContainer.style.display = 'block';
});
ytmBtn.addEventListener('click', () => {
ytmBtn.classList.remove('btn-secondary');
ytmBtn.classList.add('btn-primary');
ytmBtn.style.opacity = '1';
spotifyBtn.classList.remove('btn-primary');
spotifyBtn.classList.add('btn-secondary');
spotifyBtn.style.opacity = '0.7';
appleBtn.classList.remove('btn-primary');
appleBtn.classList.add('btn-secondary');
appleBtn.style.opacity = '0.7';
ytmGuide.style.display = 'block';
spotifyGuide.style.display = 'none';
appleGuide.style.display = 'none';
inputContainer.style.display = 'none';
});
}
// Cover image upload functionality
const coverUploadBtn = document.getElementById('playlist-cover-upload-btn');
const coverFileInput = document.getElementById('playlist-cover-file-input');
const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn');
const coverUrlInput = document.getElementById('playlist-cover-input');
const coverUploadStatus = document.getElementById('playlist-cover-upload-status');
const coverUploadText = document.getElementById('playlist-cover-upload-text');
let useUrlInput = false;
coverUploadBtn?.addEventListener('click', () => {
if (useUrlInput) return;
coverFileInput?.click();
});
coverFileInput?.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Show uploading status
coverUploadStatus.style.display = 'block';
coverUploadText.textContent = 'Uploading...';
coverUploadBtn.disabled = true;
try {
const publicUrl = await uploadCoverImage(file);
coverUrlInput.value = publicUrl;
coverUploadText.textContent = 'Done!';
coverUploadText.style.color = 'var(--success)';
setTimeout(() => {
coverUploadStatus.style.display = 'none';
}, 2000);
} catch (error) {
coverUploadText.textContent = 'Failed - try URL';
coverUploadText.style.color = 'var(--error)';
console.error('Upload failed:', error);
} finally {
coverUploadBtn.disabled = false;
}
});
coverToggleUrlBtn?.addEventListener('click', () => {
useUrlInput = !useUrlInput;
if (useUrlInput) {
coverUploadBtn.style.flex = '0 0 auto';
coverUploadBtn.style.display = 'none';
coverUrlInput.style.display = 'block';
coverToggleUrlBtn.textContent = 'Upload';
coverToggleUrlBtn.title = 'Switch to file upload';
} else {
coverUploadBtn.style.flex = '1';
coverUploadBtn.style.display = 'flex';
coverUrlInput.style.display = 'none';
coverToggleUrlBtn.textContent = 'or URL';
coverToggleUrlBtn.title = 'Switch to URL input';
}
});
document.getElementById('nav-back')?.addEventListener('click', () => {
window.history.back();
});
document.getElementById('nav-forward')?.addEventListener('click', () => {
window.history.forward();
});
document.getElementById('toggle-lyrics-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
if (!Player.instance.currentTrack) {
alert('No track is currently playing');
return;
}
const isActive = sidePanelManager.isActive('lyrics');
if (isActive) {
sidePanelManager.close();
clearLyricsPanelSync(Player.instance.activeElement, sidePanelManager.panel);
} else {
openLyricsPanel(Player.instance.currentTrack, Player.instance.activeElement, lyricsManager);
}
});
document.getElementById('download-current-btn')?.addEventListener('click', () => {
if (Player.instance.currentTrack) {
handleTrackAction(
'download',
Player.instance.currentTrack,
Player.instance,
MusicAPI.instance,
lyricsManager,
'track',
ui
);
}
});
// Auto-update lyrics when track changes
let previousTrackId = null;
audioPlayer.addEventListener('play', async () => {
if (!Player.instance.currentTrack) return;
// Update UI with current track info for theme
ui.setCurrentTrack(Player.instance.currentTrack);
// Update Media Session with new track
Player.instance.updateMediaSession(Player.instance.currentTrack);
const currentTrackId = Player.instance.currentTrack.id;
if (currentTrackId === previousTrackId) return;
previousTrackId = currentTrackId;
// Update lyrics panel if it's open
if (sidePanelManager.isActive('lyrics')) {
// Re-open forces update/refresh of content and sync
openLyricsPanel(Player.instance.currentTrack, Player.instance.activeElement, lyricsManager, true);
}
// Update Fullscreen if it's open
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
const nextTrack = Player.instance.getNextTrack();
ui.showFullscreenCover(
Player.instance.currentTrack,
nextTrack,
lyricsManager,
Player.instance.activeElement
);
}
// DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL
const urlParams = new URLSearchParams(window.location.search);
if (
urlParams.get('fullscreen') === '1' &&
fullscreenOverlay &&
getComputedStyle(fullscreenOverlay).display === 'none'
) {
const nextTrack = Player.instance.getNextTrack();
ui.showFullscreenCover(
Player.instance.currentTrack,
nextTrack,
lyricsManager,
Player.instance.activeElement
);
}
});
document.addEventListener('click', async (e) => {
if (e.target.closest('#play-album-btn')) {
const btn = e.target.closest('#play-album-btn');
if (btn.disabled) return;
const pathParts = window.location.pathname.split('/');
const albumIndex = pathParts.indexOf('album');
let albumId = albumIndex !== -1 ? pathParts[albumIndex + 1] : null;
// Handle /album/t/ID format
if (albumId === 't') {
albumId = pathParts[albumIndex + 2];
}
if (!albumId) return;
try {
const { tracks } = await MusicAPI.instance.getAlbum(albumId);
if (tracks && tracks.length > 0) {
// Sort tracks by disc and track number for consistent playback
const sortedTracks = [...tracks].sort((a, b) => {
const discA = a.volumeNumber ?? a.discNumber ?? 1;
const discB = b.volumeNumber ?? b.discNumber ?? 1;
if (discA !== discB) return discA - discB;
return a.trackNumber - b.trackNumber;
});
Player.instance.setQueue(sortedTracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false;
await Player.instance.playTrackFromQueue();
}
} catch (error) {
console.error('Failed to play album:', error);
const { showNotification } = await loadDownloadsModule();
showNotification('Failed to play album');
}
}
if (e.target.closest('#shuffle-album-btn')) {
const btn = e.target.closest('#shuffle-album-btn');
if (btn.disabled) return;
const pathParts = window.location.pathname.split('/');
const albumIndex = pathParts.indexOf('album');
let albumId = albumIndex !== -1 ? pathParts[albumIndex + 1] : null;
// Handle /album/t/ID format
if (albumId === 't') {
albumId = pathParts[albumIndex + 2];
}
if (!albumId) return;
try {
const { tracks } = await MusicAPI.instance.getAlbum(albumId);
if (tracks && tracks.length > 0) {
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
Player.instance.setQueue(shuffledTracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false;
await Player.instance.playTrackFromQueue();
const { showNotification } = await loadDownloadsModule();
showNotification('Shuffling album');
}
} catch (error) {
console.error('Failed to shuffle album:', error);
const { showNotification } = await loadDownloadsModule();
showNotification('Failed to shuffle album');
}
}
if (e.target.closest('#shuffle-artist-btn')) {
const btn = e.target.closest('#shuffle-artist-btn');
if (btn.disabled) return;
const artistId = window.location.pathname.split('/')[2];
if (!artistId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = `${SVG_ANIMATE_SPIN(18)}<span>Shuffling...</span>`;
try {
const artist = await MusicAPI.instance.getArtist(artistId);
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
const trackSet = new Set();
const allTracks = [];
// Fetch full artist discography tracks (albums + EPs), deduped by track ID.
const chunkSize = 8;
for (let i = 0; i < allReleases.length; i += chunkSize) {
const chunk = allReleases.slice(i, i + chunkSize);
await Promise.all(
chunk.map(async (album) => {
try {
const { tracks } = await MusicAPI.instance.getAlbum(album.id);
tracks.forEach((track) => {
if (!trackSet.has(track.id)) {
trackSet.add(track.id);
allTracks.push(track);
}
});
} catch (err) {
console.warn(`Failed to fetch tracks for album ${album.title}:`, err);
}
})
);
}
// Fallback to artist top tracks if discography fetch yields nothing.
if (allTracks.length === 0 && Array.isArray(artist.tracks)) {
artist.tracks.forEach((track) => {
if (!trackSet.has(track.id)) {
trackSet.add(track.id);
allTracks.push(track);
}
});
}
if (allTracks.length === 0) {
throw new Error('No tracks found for this artist');
}
const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5);
Player.instance.setQueue(shuffledTracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false;
await Player.instance.playTrackFromQueue();
const { showNotification } = await loadDownloadsModule();
showNotification('Shuffling artist discography');
} catch (error) {
console.error('Failed to shuffle artist tracks:', error);
const { showNotification } = await loadDownloadsModule();
showNotification('Failed to shuffle artist tracks');
} finally {
if (document.body.contains(btn)) {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
}
if (e.target.closest('#download-mix-btn')) {
const btn = e.target.closest('#download-mix-btn');
if (btn.disabled) return;
const mixId = window.location.pathname.split('/')[2];
if (!mixId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = `${SVG_ANIMATE_SPIN(20)}<span>Downloading...</span>`;
try {
const { mix, tracks } = await MusicAPI.instance.getMix(mixId);
const { downloadPlaylistAsZip } = await loadDownloadsModule();
await downloadPlaylistAsZip(
mix,
tracks,
MusicAPI.instance,
downloadQualitySettings.getQuality(),
lyricsManager
);
} catch (error) {
console.error('Mix download failed:', error);
alert('Failed to download mix: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
if (e.target.closest('#download-playlist-btn')) {
const btn = e.target.closest('#download-playlist-btn');
if (btn.disabled) return;
const playlistId = window.location.pathname.split('/')[2];
if (!playlistId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = `${SVG_ANIMATE_SPIN(20)}<span>Downloading...</span>`;
try {
let playlist, tracks;
let userPlaylist = await db.getPlaylist(playlistId);
if (!userPlaylist) {
try {
userPlaylist = await syncManager.getPublicPlaylist(playlistId);
} catch {
// Not a public playlist
}
}
if (userPlaylist) {
playlist = { ...userPlaylist, title: userPlaylist.name || userPlaylist.title };
tracks = userPlaylist.tracks || [];
} else {
const data = await MusicAPI.instance.getPlaylist(playlistId);
playlist = data.playlist;
tracks = data.tracks;
}
const { downloadPlaylistAsZip } = await loadDownloadsModule();
await downloadPlaylistAsZip(
playlist,
tracks,
MusicAPI.instance,
downloadQualitySettings.getQuality(),
lyricsManager
);
} catch (error) {
console.error('Playlist download failed:', error);
alert('Failed to download playlist: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
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-cover-file-input').value = '';
document.getElementById('playlist-description-input').value = '';
modal.dataset.editingId = '';
document.getElementById('import-section').style.display = 'block';
document.getElementById('csv-file-input').value = '';
document.getElementById('ytm-url-input').value = '';
document.getElementById('ytm-status').textContent = '';
document.getElementById('jspf-file-input').value = '';
document.getElementById('xspf-file-input').value = '';
document.getElementById('xml-file-input').value = '';
document.getElementById('m3u-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';
document.getElementById('xspf-import-panel').style.display = 'none';
document.getElementById('xml-import-panel').style.display = 'none';
document.getElementById('m3u-import-panel').style.display = 'none';
// Reset Public Toggle
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
if (publicToggle) publicToggle.checked = false;
if (shareBtn) shareBtn.style.display = 'none';
// Reset cover upload state
const coverUploadBtn = document.getElementById('playlist-cover-upload-btn');
const coverUrlInput = document.getElementById('playlist-cover-input');
const coverUploadStatus = document.getElementById('playlist-cover-upload-status');
const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn');
if (coverUploadBtn) {
coverUploadBtn.style.flex = '1';
coverUploadBtn.style.display = 'flex';
}
if (coverUrlInput) coverUrlInput.style.display = 'none';
if (coverUploadStatus) coverUploadStatus.style.display = 'none';
if (coverToggleUrlBtn) {
coverToggleUrlBtn.textContent = 'or URL';
coverToggleUrlBtn.title = 'Switch to URL input';
}
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
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 = '';
modal.classList.add('active');
document.getElementById('folder-name-input').focus();
}
if (e.target.closest('#folder-modal-save')) {
const name = document.getElementById('folder-name-input').value.trim();
const cover = document.getElementById('folder-cover-input').value.trim();
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');
} else {
showNotification('Please enter a folder name.');
}
}
if (e.target.closest('#folder-modal-cancel')) {
document.getElementById('folder-modal').classList.remove('active');
}
if (e.target.closest('#delete-folder-btn')) {
const folderId = window.location.pathname.split('/')[2];
if (folderId && confirm('Are you sure you want to delete this folder?')) {
await db.deleteFolder(folderId);
// Sync deletion to cloud
await syncManager.syncUserFolder({ id: folderId }, 'delete');
navigate('/library');
}
}
if (e.target.closest('#playlist-modal-save')) {
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;
const isStrictAlbumMatch = document.getElementById('strict-album-match-toggle')?.checked;
if (name) {
const modal = document.getElementById('playlist-modal');
const editingId = modal.dataset.editingId;
const handlePublicStatus = async (playlist) => {
playlist.isPublic = isPublic;
if (isPublic) {
try {
await syncManager.publishPlaylist(playlist);
} catch (e) {
console.error('Failed to publish playlist:', e);
alert('Failed to publish playlist. Please ensure you are logged in.');
}
} else {
try {
await syncManager.unpublishPlaylist(playlist.id);
} catch {
// Ignore error if it wasn't public
}
}
return playlist;
};
if (editingId) {
// Edit
const cover = document.getElementById('playlist-cover-input').value.trim();
db.getPlaylist(editingId).then(async (playlist) => {
if (playlist) {
playlist.name = name;
playlist.cover = cover;
playlist.description = description;
await handlePublicStatus(playlist);
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'update');
ui.renderLibraryPage();
// Also update current page if we are on it
if (window.location.pathname === `/userplaylist/${editingId}`) {
ui.renderPlaylistPage(editingId, 'user');
}
modal.classList.remove('active');
delete modal.dataset.editingId;
}
});
} else {
// Create
const csvFileInput = document.getElementById('csv-file-input');
const jspfFileInput = document.getElementById('jspf-file-input');
const xspfFileInput = document.getElementById('xspf-file-input');
const xmlFileInput = document.getElementById('xml-file-input');
const m3uFileInput = document.getElementById('m3u-file-input');
const importOptions = { strictArtistMatch: true, strictAlbumMatch: isStrictAlbumMatch };
let tracks = [];
let importSource = 'manual';
let cover = document.getElementById('playlist-cover-input').value.trim();
// Helper function for import progress
const setupProgressElements = () => {
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');
return {
progressElement,
progressFill,
progressCurrent,
progressTotal,
currentTrackElement,
currentArtistElement,
};
};
const isYTMActive = document.getElementById('csv-ytm-btn')?.classList.contains('btn-primary');
const ytmUrlInput = document.getElementById('ytm-url-input');
if (isYTMActive && ytmUrlInput.value.trim()) {
importSource = 'ytm_import';
const url = ytmUrlInput.value.trim();
const playlistId = url.split('list=')[1]?.split('&')[0];
const workerUrl = `https://ytmimport.samidy.workers.dev?playlistId=${playlistId}`;
if (!playlistId) {
alert("Invalid URL. Make sure it has 'list=' in it.");
return;
}
const {
progressElement,
progressFill,
progressCurrent,
progressTotal,
currentTrackElement,
currentArtistElement,
} = setupProgressElements();
try {
progressElement.style.display = 'block';
progressFill.style.width = '0%';
progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Fetching from YouTube...';
if (currentArtistElement) currentArtistElement.textContent = '';
const response = await fetch(workerUrl);
const songs = await response.json();
if (songs.error) throw new Error(songs.error);
currentTrackElement.textContent = `Processing ${songs.length} songs...`;
const headers = 'Title,Artist,URL\n';
const csvText =
headers +
songs
.map(
(s) =>
`"${s.title.replace(/"/g, '""')}","${s.artist.replace(/"/g, '""')}","${s.url}"`
)
.join('\n');
const totalTracks = songs.length;
progressTotal.textContent = totalTracks.toString();
const result = await parseCSV(
csvText,
MusicAPI.instance,
(progress) => {
const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0;
progressFill.style.width = `${Math.min(percentage, 100)}%`;
progressCurrent.textContent = progress.current.toString();
currentTrackElement.textContent = progress.currentTrack;
if (currentArtistElement)
currentArtistElement.textContent = progress.currentArtist || '';
},
importOptions
);
tracks = result.tracks;
const missingTracks = result.missingTracks;
if (tracks.length === 0) {
alert('No valid tracks found in the YouTube playlist!');
progressElement.style.display = 'none';
return;
}
console.log(`Imported ${tracks.length} tracks from YouTube`);
trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length);
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
}, 500);
}
} catch (err) {
console.error('YTM Import Error:', err);
alert(`Error importing from YouTube: ${err.message}`);
progressElement.style.display = 'none';
return;
} finally {
setTimeout(() => {
progressElement.style.display = 'none';
}, 1000);
}
} else if (jspfFileInput.files.length > 0) {
// Import from JSPF
importSource = 'jspf_import';
const file = jspfFileInput.files[0];
const {
progressElement,
progressFill,
progressCurrent,
progressTotal,
currentTrackElement,
currentArtistElement,
} = setupProgressElements();
try {
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, MusicAPI.instance, (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 (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
}, 500);
}
} catch (error) {
console.error('Failed to parse JSPF!', error);
alert('Failed to parse JSPF file! ' + error.message);
progressElement.style.display = 'none';
return;
} finally {
setTimeout(() => {
progressElement.style.display = 'none';
}, 1000);
}
} else if (csvFileInput.files.length > 0) {
const file = csvFileInput.files[0];
const {
progressElement,
progressFill,
progressCurrent,
progressTotal,
currentTrackElement,
currentArtistElement,
} = setupProgressElements();
try {
progressElement.style.display = 'block';
progressFill.style.width = '0%';
progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Reading CSV file...';
if (currentArtistElement) currentArtistElement.textContent = '';
const csvText = await file.text();
const lines = csvText.trim().split('\n');
const totalItems = Math.max(0, lines.length - 1);
progressTotal.textContent = totalItems.toString();
const result = await parseDynamicCSV(
csvText,
MusicAPI.instance,
(progress) => {
const percentage = totalItems > 0 ? (progress.current / totalItems) * 100 : 0;
progressFill.style.width = `${Math.min(percentage, 100)}%`;
progressCurrent.textContent = progress.current.toString();
currentTrackElement.textContent = progress.currentItem;
if (currentArtistElement) {
currentArtistElement.textContent = progress.type
? `Importing ${progress.type}...`
: '';
}
},
importOptions
);
const isLibraryImport =
result.albums.length > 0 ||
result.artists.length > 0 ||
Object.keys(result.playlists).length > 1;
if (isLibraryImport) {
currentTrackElement.textContent = 'Adding to library...';
const importResults = await importToLibrary(
result,
db,
(progress) => {
if (progress.action === 'playlist') {
currentTrackElement.textContent = `Creating playlist: ${progress.item}`;
} else {
currentTrackElement.textContent = `Adding ${progress.action}: ${progress.item}`;
}
},
{
favoriteTracks: false,
favoriteAlbums: false,
favoriteArtists: false,
}
);
console.log('Import results:', importResults);
const summary = [];
if (importResults.tracks.added > 0)
summary.push(`${importResults.tracks.added} tracks`);
if (importResults.albums.added > 0)
summary.push(`${importResults.albums.added} albums`);
if (importResults.artists.added > 0)
summary.push(`${importResults.artists.added} artists`);
if (importResults.playlists.created > 0)
summary.push(`${importResults.playlists.created} playlists`);
alert(
`Imported to library:\n${summary.join(', ')}\n\n${
result.missingItems.length > 0
? `${result.missingItems.length} items could not be found.`
: ''
}`
);
progressElement.style.display = 'none';
}
tracks = result.tracks;
const missingTracks = result.missingItems.filter((i) => i.type === 'track');
if (tracks.length === 0) {
alert('No valid tracks found in the CSV file! Please check the format.');
progressElement.style.display = 'none';
return;
}
console.log(`Imported ${tracks.length} tracks from CSV`);
trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length);
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
}, 500);
}
} catch (error) {
console.error('Failed to parse CSV!', error);
alert('Failed to parse CSV file! ' + error.message);
progressElement.style.display = 'none';
return;
} finally {
setTimeout(() => {
progressElement.style.display = 'none';
}, 1000);
}
} else if (xspfFileInput.files.length > 0) {
// Import from XSPF
importSource = 'xspf_import';
const file = xspfFileInput.files[0];
const {
progressElement,
progressFill,
progressCurrent,
progressTotal,
currentTrackElement,
currentArtistElement,
} = setupProgressElements();
try {
progressElement.style.display = 'block';
progressFill.style.width = '0%';
progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Reading XSPF file...';
if (currentArtistElement) currentArtistElement.textContent = '';
const xspfText = await file.text();
const result = await parseXSPF(xspfText, MusicAPI.instance, (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 XSPF file! Please check the format.');
progressElement.style.display = 'none';
return;
}
console.log(`Imported ${tracks.length} tracks from XSPF`);
trackImportXSPF(name || 'Untitled', tracks.length, missingTracks.length);
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
}, 500);
}
} catch (error) {
console.error('Failed to parse XSPF!', error);
alert('Failed to parse XSPF file! ' + error.message);
progressElement.style.display = 'none';
return;
} finally {
setTimeout(() => {
progressElement.style.display = 'none';
}, 1000);
}
} else if (xmlFileInput.files.length > 0) {
// Import from XML
importSource = 'xml_import';
const file = xmlFileInput.files[0];
const {
progressElement,
progressFill,
progressCurrent,
progressTotal,
currentTrackElement,
currentArtistElement,
} = setupProgressElements();
try {
progressElement.style.display = 'block';
progressFill.style.width = '0%';
progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Reading XML file...';
if (currentArtistElement) currentArtistElement.textContent = '';
const xmlText = await file.text();
const result = await parseXML(xmlText, MusicAPI.instance, (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 XML file! Please check the format.');
progressElement.style.display = 'none';
return;
}
console.log(`Imported ${tracks.length} tracks from XML`);
trackImportXML(name || 'Untitled', tracks.length, missingTracks.length);
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
}, 500);
}
} catch (error) {
console.error('Failed to parse XML!', error);
alert('Failed to parse XML file! ' + error.message);
progressElement.style.display = 'none';
return;
} finally {
setTimeout(() => {
progressElement.style.display = 'none';
}, 1000);
}
} else if (m3uFileInput.files.length > 0) {
// Import from M3U/M3U8
importSource = 'm3u_import';
const file = m3uFileInput.files[0];
const {
progressElement,
progressFill,
progressCurrent,
progressTotal,
currentTrackElement,
currentArtistElement,
} = setupProgressElements();
try {
progressElement.style.display = 'block';
progressFill.style.width = '0%';
progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Reading M3U file...';
if (currentArtistElement) currentArtistElement.textContent = '';
const m3uText = await file.text();
const result = await parseM3U(m3uText, MusicAPI.instance, (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 M3U file! Please check the format.');
progressElement.style.display = 'none';
return;
}
console.log(`Imported ${tracks.length} tracks from M3U`);
trackImportM3U(name || 'Untitled', tracks.length, missingTracks.length);
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
}, 500);
}
} catch (error) {
console.error('Failed to parse M3U!', error);
alert('Failed to parse M3U file! ' + error.message);
progressElement.style.display = 'none';
return;
} finally {
setTimeout(() => {
progressElement.style.display = 'none';
}, 1000);
}
}
// Check for pending tracks (from Add to Playlist -> New Playlist)
const modal = document.getElementById('playlist-modal');
if (modal._pendingTracks && Array.isArray(modal._pendingTracks)) {
tracks = [...tracks, ...modal._pendingTracks];
delete modal._pendingTracks;
// Also clear CSV input if we came from there? No, keep it separate.
console.log(`Added ${tracks.length} tracks (including pending)`);
}
db.createPlaylist(name, tracks, cover, description).then(async (playlist) => {
await handlePublicStatus(playlist);
// 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');
});
}
} else {
showNotification('Please enter a playlist name.');
}
}
if (e.target.closest('#playlist-modal-cancel')) {
document.getElementById('playlist-modal').classList.remove('active');
}
if (e.target.closest('.edit-playlist-btn')) {
const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId;
db.getPlaylist(playlistId).then(async (playlist) => {
if (playlist) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-name-input').value = playlist.name;
document.getElementById('playlist-cover-input').value = playlist.cover || '';
document.getElementById('playlist-description-input').value = playlist.description || '';
// Set Public Toggle
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
// Check if actually public in Pocketbase to be sure (async) or trust local flag
// We trust local flag for UI speed, but could verify.
if (publicToggle) publicToggle.checked = !!playlist.isPublic;
if (shareBtn) {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => {
const url = getShareUrl(`/userplaylist/${playlist.id}`);
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
};
}
// Set cover upload state - show URL input if there's an existing cover
const coverUploadBtn = document.getElementById('playlist-cover-upload-btn');
const coverUrlInput = document.getElementById('playlist-cover-input');
const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn');
if (playlist.cover) {
if (coverUploadBtn) coverUploadBtn.style.display = 'none';
if (coverUrlInput) coverUrlInput.style.display = 'block';
if (coverToggleUrlBtn) {
coverToggleUrlBtn.textContent = 'Upload';
coverToggleUrlBtn.title = 'Switch to file upload';
}
} else {
if (coverUploadBtn) {
coverUploadBtn.style.flex = '1';
coverUploadBtn.style.display = 'flex';
}
if (coverUrlInput) coverUrlInput.style.display = 'none';
if (coverToggleUrlBtn) {
coverToggleUrlBtn.textContent = 'or URL';
coverToggleUrlBtn.title = 'Switch to URL input';
}
}
modal.dataset.editingId = playlistId;
document.getElementById('import-section').style.display = 'none';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
});
}
if (e.target.closest('.delete-playlist-btn')) {
const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId;
if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => {
syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
ui.renderLibraryPage();
});
}
}
if (e.target.closest('#edit-playlist-btn')) {
const playlistId = window.location.pathname.split('/')[2];
db.getPlaylist(playlistId).then((playlist) => {
if (playlist) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-name-input').value = playlist.name;
document.getElementById('playlist-cover-input').value = playlist.cover || '';
document.getElementById('playlist-description-input').value = playlist.description || '';
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
if (publicToggle) publicToggle.checked = !!playlist.isPublic;
if (shareBtn) {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => {
const url = getShareUrl(`/userplaylist/${playlist.id}`);
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
};
}
// Set cover upload state - show URL input if there's an existing cover
const coverUploadBtn = document.getElementById('playlist-cover-upload-btn');
const coverUrlInput = document.getElementById('playlist-cover-input');
const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn');
if (playlist.cover) {
if (coverUploadBtn) coverUploadBtn.style.display = 'none';
if (coverUrlInput) coverUrlInput.style.display = 'block';
if (coverToggleUrlBtn) {
coverToggleUrlBtn.textContent = 'Upload';
coverToggleUrlBtn.title = 'Switch to file upload';
}
} else {
if (coverUploadBtn) {
coverUploadBtn.style.flex = '1';
coverUploadBtn.style.display = 'flex';
}
if (coverUrlInput) coverUrlInput.style.display = 'none';
if (coverToggleUrlBtn) {
coverToggleUrlBtn.textContent = 'or URL';
coverToggleUrlBtn.title = 'Switch to URL input';
}
}
modal.dataset.editingId = playlistId;
document.getElementById('import-section').style.display = 'none';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
});
}
if (e.target.closest('#delete-playlist-btn')) {
const playlistId = window.location.pathname.split('/')[2];
if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => {
syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
navigate('/library');
});
}
}
if (e.target.closest('.remove-from-playlist-btn')) {
e.stopPropagation();
const btn = e.target.closest('.remove-from-playlist-btn');
const playlistId = window.location.pathname.split('/')[2];
db.getPlaylist(playlistId).then(async (playlist) => {
let trackId = null;
let trackType = null;
// Prefer ID if available (from sorted view)
if (btn.dataset.trackId) {
trackId = btn.dataset.trackId;
trackType = btn.dataset.type || 'track';
} else if (btn.dataset.trackIndex) {
// Fallback to index (legacy/unsorted)
const index = parseInt(btn.dataset.trackIndex);
if (playlist && playlist.tracks[index]) {
trackId = playlist.tracks[index].id;
trackType = playlist.tracks[index].type || 'track';
}
}
if (trackId) {
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType);
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
const scrollTop = document.querySelector('.main-content').scrollTop;
await ui.renderPlaylistPage(playlistId, 'user');
document.querySelector('.main-content').scrollTop = scrollTop;
}
});
}
if (e.target.closest('#play-playlist-btn')) {
const btn = e.target.closest('#play-playlist-btn');
if (btn.disabled) return;
const playlistId = window.location.pathname.split('/')[2];
if (!playlistId) return;
try {
let tracks;
const userPlaylist = await db.getPlaylist(playlistId);
if (userPlaylist) {
tracks = userPlaylist.tracks;
} else {
// Try API, if fail, try Public Pocketbase
try {
const { tracks: apiTracks } = await MusicAPI.instance.getPlaylist(playlistId);
tracks = apiTracks;
} catch (e) {
const publicPlaylist = await syncManager.getPublicPlaylist(playlistId);
if (publicPlaylist) {
tracks = publicPlaylist.tracks;
} else {
throw e;
}
}
}
if (tracks.length > 0) {
Player.instance.setQueue(tracks, 0);
document.getElementById('shuffle-btn').classList.remove('active');
await Player.instance.playTrackFromQueue();
}
} catch (error) {
console.error('Failed to play playlist:', error);
alert('Failed to play playlist: ' + error.message);
}
}
if (e.target.closest('#download-album-btn')) {
const btn = e.target.closest('#download-album-btn');
if (btn.disabled) return;
const albumId = window.location.pathname.split('/')[2];
if (!albumId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = `${SVG_ANIMATE_SPIN(20)}<span>Downloading...</span>`;
try {
const { album, tracks } = await MusicAPI.instance.getAlbum(albumId);
const { downloadAlbumAsZip } = await loadDownloadsModule();
await downloadAlbumAsZip(
album,
tracks,
MusicAPI.instance,
downloadQualitySettings.getQuality(),
lyricsManager
);
} catch (error) {
console.error('Album download failed:', error);
alert('Failed to download album: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
if (e.target.closest('#add-album-to-playlist-btn')) {
const btn = e.target.closest('#add-album-to-playlist-btn');
if (btn.disabled) return;
const albumId = window.location.pathname.split('/')[2];
if (!albumId) return;
try {
const { tracks } = await MusicAPI.instance.getAlbum(albumId);
if (!tracks || tracks.length === 0) {
const { showNotification } = await loadDownloadsModule();
showNotification('No tracks found in this album.');
return;
}
const modal = document.getElementById('playlist-select-modal');
const list = document.getElementById('playlist-select-list');
const cancelBtn = document.getElementById('playlist-select-cancel');
const overlay = modal.querySelector('.modal-overlay');
const playlists = await db.getPlaylists(false);
list.innerHTML =
`
<div class="modal-option create-new-option" style="border-bottom: 1px solid var(--border); margin-bottom: 0.5rem;">
<span style="font-weight: 600; color: var(--primary);">+ Create New Playlist</span>
</div>
` +
playlists
.map(
(p) => `
<div class="modal-option" data-id="${p.id}">
<span>${p.name}</span>
</div>
`
)
.join('');
const closeModal = () => {
modal.classList.remove('active');
cleanup();
};
const handleOptionClick = async (e) => {
const option = e.target.closest('.modal-option');
if (!option) return;
if (option.classList.contains('create-new-option')) {
closeModal();
const createModal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
document.getElementById('playlist-name-input').value = '';
document.getElementById('playlist-cover-input').value = '';
createModal.dataset.editingId = '';
document.getElementById('import-section').style.display = 'none'; // Hide import for simple add
// Pass tracks
createModal._pendingTracks = tracks;
createModal.classList.add('active');
document.getElementById('playlist-name-input').focus();
return;
}
const playlistId = option.dataset.id;
try {
await db.addTracksToPlaylist(playlistId, tracks);
const updatedPlaylist = await db.getPlaylist(playlistId);
await syncManager.syncUserPlaylist(updatedPlaylist, 'update');
const { showNotification } = await loadDownloadsModule();
showNotification(`Added ${tracks.length} tracks to playlist.`);
closeModal();
} catch (err) {
console.error(err);
const { showNotification } = await loadDownloadsModule();
showNotification('Failed to add tracks.');
}
};
const cleanup = () => {
cancelBtn.removeEventListener('click', closeModal);
overlay.removeEventListener('click', closeModal);
list.removeEventListener('click', handleOptionClick);
};
cancelBtn.addEventListener('click', closeModal);
overlay.addEventListener('click', closeModal);
list.addEventListener('click', handleOptionClick);
modal.classList.add('active');
} catch (error) {
console.error('Failed to prepare album for playlist:', error);
const { showNotification } = await loadDownloadsModule();
showNotification('Failed to load album tracks.');
}
}
if (e.target.closest('#play-artist-radio-btn')) {
const btn = e.target.closest('#play-artist-radio-btn');
if (btn.disabled) return;
const artistId = window.location.pathname.split('/')[2];
if (!artistId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = `${SVG_ANIMATE_SPIN(20)}<span>Loading...</span>`;
try {
const artist = await MusicAPI.instance.getArtist(artistId);
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
if (allReleases.length === 0) {
throw new Error('No albums or EPs found for this artist');
}
const trackSet = new Set();
const allTracks = [];
const chunks = [];
const chunkSize = 3;
const albums = allReleases;
for (let i = 0; i < albums.length; i += chunkSize) {
chunks.push(albums.slice(i, i + chunkSize));
}
for (const chunk of chunks) {
await Promise.all(
chunk.map(async (album) => {
try {
const { tracks } = await MusicAPI.instance.getAlbum(album.id);
tracks.forEach((track) => {
if (!trackSet.has(track.id)) {
trackSet.add(track.id);
allTracks.push(track);
}
});
} catch (err) {
console.warn(`Failed to fetch tracks for album ${album.title}:`, err);
}
})
);
}
if (allTracks.length > 0) {
for (let i = allTracks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allTracks[i], allTracks[j]] = [allTracks[j], allTracks[i]];
}
Player.instance.setQueue(allTracks, 0);
await Player.instance.playTrackFromQueue();
} else {
throw new Error('No tracks found across all albums');
}
} catch (error) {
console.error('Artist radio failed:', error);
alert('Failed to start artist radio: ' + error.message);
} finally {
if (document.body.contains(btn)) {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
}
if (e.target.closest('#shuffle-liked-tracks-btn')) {
const btn = e.target.closest('#shuffle-liked-tracks-btn');
if (btn.disabled) return;
try {
const likedTracks = await db.getFavorites('track');
if (likedTracks.length > 0) {
// Shuffle array
for (let i = likedTracks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[likedTracks[i], likedTracks[j]] = [likedTracks[j], likedTracks[i]];
}
Player.instance.setQueue(likedTracks, 0);
document.getElementById('shuffle-btn').classList.remove('active');
await Player.instance.playTrackFromQueue();
}
} catch (error) {
console.error('Failed to shuffle liked tracks:', error);
}
}
if (e.target.closest('#download-liked-tracks-btn')) {
const btn = e.target.closest('#download-liked-tracks-btn');
if (btn.disabled) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = SVG_ANIMATE_SPIN(16);
try {
const likedTracks = await db.getFavorites('track');
if (likedTracks.length === 0) {
alert('No liked tracks to download.');
return;
}
const { downloadLikedTracks } = await loadDownloadsModule();
await downloadLikedTracks(
likedTracks,
MusicAPI.instance,
downloadQualitySettings.getQuality(),
lyricsManager
);
} catch (error) {
console.error('Liked tracks download failed:', error);
alert('Failed to download liked tracks: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
if (e.target.closest('#download-discography-btn')) {
const btn = e.target.closest('#download-discography-btn');
if (btn.disabled) return;
const artistId = window.location.pathname.split('/')[2];
if (!artistId) return;
try {
const artist = await MusicAPI.instance.getArtist(artistId);
showDiscographyDownloadModal(
artist,
MusicAPI.instance,
downloadQualitySettings.getQuality(),
lyricsManager,
btn
);
} catch (error) {
console.error('Failed to load artist for discography download:', error);
alert('Failed to load artist: ' + error.message);
}
}
// 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 isNeutralino =
window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino'));
let handle;
let path;
if (isNeutralino) {
path = await window.Neutralino.os.showFolderDialog('Select Music Folder');
if (!path) return;
// Mock a handle object for UI compatibility
handle = { name: path.split(/[/\\]/).pop() || path, isNeutralino: true, path };
} else {
handle = await window.showDirectoryPicker({
id: 'music-folder',
mode: 'read',
});
}
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');
if (btn) {
if (btnText) btnText.textContent = 'Scanning...';
else btn.textContent = 'Scanning...';
btn.disabled = true;
}
const tracks = scanLocalMediaFolder(true);
trackSelectLocalFolder(tracks?.length ?? 0);
ui.renderLibraryPage();
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error selecting folder:', err);
alert('Failed to access folder. Please try again.');
}
const btn = document.getElementById('select-local-folder-btn');
const btnText = document.getElementById('select-local-folder-text');
if (btn) {
if (btnText) btnText.textContent = 'Select Music Folder';
else btn.textContent = 'Select Music Folder';
btn.disabled = false;
}
}
}
});
const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
ui.setupSearchClearButton(searchInput);
const performSearch = (query) => {
if (query) {
navigate(`/search/${encodeURIComponent(query)}`);
}
};
const debouncedSearch = debounce((query) => {
if (query && query === searchInput.value.trim()) {
performSearch(query);
}
}, 3000);
const handleExternalLink = (query) => {
const isExternalLink =
query.includes('monochrome.tf/') ||
query.includes('monochrome.samidy.com/') ||
query.includes('tidal.com/');
if (isExternalLink) {
const url = query.startsWith('http') ? query : 'https://' + query;
try {
const urlObj = new URL(url);
let path = urlObj.pathname;
path = path.replace(/\/+$/, '');
const segments = path.split('/').filter((s) => s);
if (segments.length >= 2) {
path = '/' + segments[0] + '/' + segments[1];
}
navigate(path);
return true;
} catch {
return false;
}
}
return false;
};
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (!query) return;
if (handleExternalLink(query)) {
return;
}
debouncedSearch(query);
});
searchInput.addEventListener('change', (e) => {
const query = e.target.value.trim();
if (query) {
ui.addToSearchHistory(query);
}
});
searchInput.addEventListener('focus', () => {
ui.renderSearchHistory();
});
searchInput.addEventListener('click', () => {
ui.renderSearchHistory();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-bar')) {
const historyEl = document.getElementById('search-history');
if (historyEl) historyEl.style.display = 'none';
}
});
searchForm.addEventListener('submit', (e) => {
e.preventDefault();
const query = searchInput.value.trim();
if (!query) return;
if (!handleExternalLink(query)) {
ui.addToSearchHistory(query);
performSearch(query);
const historyEl = document.getElementById('search-history');
if (historyEl) historyEl.style.display = 'none';
}
});
window.addEventListener('online', () => {
hideOfflineNotification();
console.log('Back online');
});
window.addEventListener('offline', () => {
showOfflineNotification();
console.log('Gone offline');
});
document.querySelector('.now-playing-bar .play-pause-btn').innerHTML = SVG_PLAY(20);
const router = createRouter(ui);
const handleRouteChange = async (event) => {
const overlay = document.getElementById('fullscreen-cover-overlay');
const isFullscreenOpen = overlay && getComputedStyle(overlay).display === 'flex';
if (isFullscreenOpen && window.location.hash !== '#fullscreen') {
ui.closeFullscreenCover();
}
if (event && event.state && event.state.exitTrap) {
const { showNotification } = await loadDownloadsModule();
showNotification('Press back again to exit');
setTimeout(() => {
if (history.state && history.state.exitTrap) {
history.pushState({ app: true }, '', window.location.pathname);
}
}, 2000);
return;
}
// Intercept back navigation to close modals first if setting is enabled
if (event && modalSettings.shouldInterceptBackToClose() && modalSettings.hasOpenModalsOrPanels()) {
sidePanelManager.close();
modalSettings.closeAllModals();
history.pushState(history.state || { app: true }, '', window.location.pathname);
return;
}
// Close side panel (queue/lyrics) and modals on navigation if setting is enabled
if (modalSettings.shouldCloseOnNavigation()) {
sidePanelManager.close();
modalSettings.closeAllModals();
}
await router();
updateTabTitle(Player.instance);
};
await handleRouteChange();
window.addEventListener('popstate', handleRouteChange);
document.body.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (
link &&
link.origin === window.location.origin &&
link.target !== '_blank' &&
!link.hasAttribute('download')
) {
e.preventDefault();
navigate(link.pathname);
}
});
audioPlayer.addEventListener('play', () => {
updateTabTitle(Player.instance);
});
// PWA Update Logic
if (window.__AUTH_GATE__) {
disablePwaForAuthGate();
} else {
const updateSW = registerSW({
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(() => {
trackPwaUpdate();
updateSW(true);
});
}
},
onOfflineReady() {
console.log('App ready to work offline');
},
});
}
document.getElementById('show-shortcuts-btn')?.addEventListener('click', () => {
showKeyboardShortcuts();
});
document.getElementById('customize-shortcuts-btn')?.addEventListener('click', () => {
showCustomizeShortcutsModal();
});
// Font Settings
const fontSelect = document.getElementById('font-select');
if (fontSelect) {
const savedFont = localStorage.getItem('monochrome-font');
if (savedFont) {
fontSelect.value = savedFont;
}
fontSelect.addEventListener('change', (e) => {
const font = e.target.value;
document.documentElement.style.setProperty('--font-family', font);
localStorage.setItem('monochrome-font', font);
});
}
// Listener for Pocketbase Sync updates
window.addEventListener('library-changed', () => {
const path = window.location.pathname;
if (path === '/library') {
ui.renderLibraryPage();
} else if (path === '/' || path === '/home') {
ui.renderHomePage();
} else if (path.startsWith('/userplaylist/')) {
const playlistId = path.split('/')[2];
const content = document.querySelector('.main-content');
const scroll = content ? content.scrollTop : 0;
ui.renderPlaylistPage(playlistId, 'user').then(() => {
if (content) content.scrollTop = scroll;
});
}
});
window.addEventListener('history-changed', () => {
const path = window.location.pathname;
if (path === '/recent') {
ui.renderRecentPage();
}
});
const contextMenu = document.getElementById('context-menu');
if (contextMenu) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
if (contextMenu.style.display === 'block') {
const track = contextMenu._contextTrack;
const albumItem = contextMenu.querySelector('[data-action="go-to-album"]');
if (track) {
if (albumItem) {
let label = 'album';
const albumType = track.album?.type?.toUpperCase();
const trackCount = track.album?.numberOfTracks;
if (albumType === 'SINGLE' || trackCount === 1) label = 'single';
else if (albumType === 'EP') label = 'EP';
else if (trackCount && trackCount <= 6) label = 'EP';
albumItem.textContent = `Go to ${label}`;
albumItem.style.display = track.album ? 'block' : 'none';
}
}
}
}
});
});
observer.observe(contextMenu, { attributes: true });
}
const headerAccountBtn = document.getElementById('header-account-btn');
const headerAccountDropdown = document.getElementById('header-account-dropdown');
const headerAccountImg = document.getElementById('header-account-img');
const headerAccountIcon = document.getElementById('header-account-icon');
// Temporarily disable accounts - show popup
const isAccountsDisabled = false;
if (headerAccountBtn && headerAccountDropdown) {
if (isAccountsDisabled) {
headerAccountBtn.style.opacity = '0.5';
headerAccountBtn.style.cursor = 'not-allowed';
headerAccountBtn.title = 'Accounts temporarily unavailable';
headerAccountBtn.addEventListener('click', (e) => {
e.stopPropagation();
alert(
"We're moving authentication and data storing systems.\n\nAccounts, profiles, playlists, and community themes will not work during this period (approximately 2 days).\n\nYou will need to re-login after the migration is complete."
);
});
} else {
headerAccountBtn.addEventListener('click', (e) => {
e.stopPropagation();
headerAccountDropdown.classList.toggle('active');
updateAccountDropdown();
});
}
document.addEventListener('click', (e) => {
if (!headerAccountBtn.contains(e.target) && !headerAccountDropdown.contains(e.target)) {
headerAccountDropdown.classList.remove('active');
}
});
async function updateAccountDropdown() {
const user = authManager?.user;
headerAccountDropdown.innerHTML = '';
if (!user) {
headerAccountDropdown.innerHTML = `
<button class="btn-secondary" id="header-google-auth">Connect with Google</button>
<button class="btn-secondary" id="header-email-auth">Connect with Email</button>
`;
document.getElementById('header-google-auth').onclick = () => authManager.signInWithGoogle();
document.getElementById('header-email-auth').onclick = () => {
document.getElementById('email-auth-modal').classList.add('active');
headerAccountDropdown.classList.remove('active');
};
} else {
const data = await syncManager.getUserData();
const hasProfile = data && data.profile && data.profile.username;
if (hasProfile) {
headerAccountDropdown.innerHTML = `
<button class="btn-secondary" id="header-view-profile">My Profile</button>
<button class="btn-secondary danger" id="header-sign-out">Sign Out</button>
`;
document.getElementById('header-view-profile').onclick = () => {
navigate(`/user/@${data.profile.username}`);
headerAccountDropdown.classList.remove('active');
};
} else {
headerAccountDropdown.innerHTML = `
<button class="btn-primary" id="header-create-profile">Create Profile</button>
<button class="btn-secondary danger" id="header-sign-out">Sign Out</button>
`;
document.getElementById('header-create-profile').onclick = () => {
openEditProfile();
headerAccountDropdown.classList.remove('active');
};
}
document.getElementById('header-sign-out').onclick = () => authManager.signOut();
}
}
authManager.onAuthStateChanged(async (user) => {
if (user) {
const data = await syncManager.getUserData();
if (data && data.profile && data.profile.avatar_url) {
headerAccountImg.src = data.profile.avatar_url;
headerAccountImg.style.display = 'block';
headerAccountIcon.style.display = 'none';
return;
}
}
headerAccountImg.style.display = 'none';
headerAccountIcon.style.display = 'block';
});
}
});
function showUpdateNotification(updateCallback) {
// Remove any existing update notification
const existingNotification = document.querySelector('.update-notification');
if (existingNotification) {
existingNotification.remove();
}
const notification = document.createElement('div');
notification.className = 'update-notification';
notification.innerHTML = `
<div>
<strong>Update Available</strong>
<p>A new version of Monochrome is available.</p>
</div>
<div class="update-notification-actions">
<button class="btn-primary" id="update-now-btn">Update Now</button>
<button class="btn-icon" id="dismiss-update-btn" title="Dismiss">
${SVG_CLOSE(16)}
</button>
</div>
`;
document.body.appendChild(notification);
document.getElementById('update-now-btn').addEventListener('click', () => {
if (typeof updateCallback === 'function') {
updateCallback();
} else if (updateCallback && updateCallback.postMessage) {
updateCallback.postMessage({ action: 'skipWaiting' });
} else {
window.location.reload();
}
});
document.getElementById('dismiss-update-btn').addEventListener('click', () => {
trackDismissUpdate();
notification.remove();
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showMissingTracksNotification(missingTracks, playlistName) {
const modal = document.getElementById('missing-tracks-modal');
const listUl = document.getElementById('missing-tracks-list-ul');
const copyBtn = document.getElementById('copy-missing-tracks-btn');
const exportCSVBtn = document.getElementById('export-missing-tracks-csv-btn');
listUl.innerHTML = missingTracks
.map((track) => {
const text =
typeof track === 'string' ? track : `${track.artist ? track.artist + ' - ' : ''}${track.title}`;
return `<li>${escapeHtml(text)}</li>`;
})
.join('');
if (copyBtn) {
const newCopyBtn = copyBtn.cloneNode(true);
copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn);
newCopyBtn.addEventListener('click', () => {
const header = `Missing songs from ${playlistName} import:\n\n`;
const textToCopy =
header +
missingTracks
.map((track) => {
return typeof track === 'string'
? track
: `${track.artist ? track.artist + ' - ' : ''}${track.title}`;
})
.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
const originalText = newCopyBtn.textContent;
newCopyBtn.textContent = 'Copied!';
setTimeout(() => (newCopyBtn.textContent = originalText), 2000);
});
});
}
if (exportCSVBtn) {
const newExportBtn = exportCSVBtn.cloneNode(true);
exportCSVBtn.parentNode.replaceChild(newExportBtn, exportCSVBtn);
newExportBtn.addEventListener('click', () => {
const headers = ['Artist', 'Title', 'Album'];
let csvContent = headers.join(',') + '\n';
missingTracks.forEach((track) => {
if (typeof track === 'string') {
csvContent += `"${track.replace(/"/g, '""')}","",""\n`;
} else {
const artist = (track.artist || '').replace(/"/g, '""');
const title = (track.title || '').replace(/"/g, '""');
const album = (track.album || '').replace(/"/g, '""');
csvContent += `"${artist}","${title}","${album}"\n`;
}
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
const fileName = `${playlistName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_missing_tracks.csv`;
link.setAttribute('download', fileName);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
const closeModal = () => modal.classList.remove('active');
// Remove old listeners if any (though usually these functions are called once per instance,
// but since we reuse the same modal element we should be careful or use a one-time listener)
const handleClose = (e) => {
if (
e.target === modal ||
e.target.closest('.close-missing-tracks') ||
e.target.id === 'close-missing-tracks-btn' ||
e.target.classList.contains('modal-overlay')
) {
closeModal();
modal.removeEventListener('click', handleClose);
}
};
modal.addEventListener('click', handleClose);
modal.classList.add('active');
}
function showDiscographyDownloadModal(artist, api, quality, lyricsManager, triggerBtn) {
const modal = document.getElementById('discography-download-modal');
document.getElementById('discography-artist-name').textContent = artist.name;
document.getElementById('albums-count').textContent = artist.albums?.length || 0;
document.getElementById('eps-count').textContent = (artist.eps || []).filter((a) => a.type === 'EP').length;
document.getElementById('singles-count').textContent = (artist.eps || []).filter((a) => a.type === 'SINGLE').length;
// Reset checkboxes
document.getElementById('download-albums').checked = true;
document.getElementById('download-eps').checked = true;
document.getElementById('download-singles').checked = true;
const closeModal = () => {
modal.classList.remove('active');
};
const handleClose = (e) => {
if (
e.target === modal ||
e.target.classList.contains('modal-overlay') ||
e.target.closest('.close-modal-btn') ||
e.target.id === 'cancel-discography-download'
) {
closeModal();
}
};
modal.addEventListener('click', handleClose);
document.getElementById('start-discography-download').onclick = async () => {
const includeAlbums = document.getElementById('download-albums').checked;
const includeEPs = document.getElementById('download-eps').checked;
const includeSingles = document.getElementById('download-singles').checked;
if (!includeAlbums && !includeEPs && !includeSingles) {
alert('Please select at least one type of release to download.');
return;
}
closeModal();
// Filter releases based on selection
let selectedReleases = [];
if (includeAlbums) {
selectedReleases = selectedReleases.concat(artist.albums || []);
}
if (includeEPs) {
selectedReleases = selectedReleases.concat((artist.eps || []).filter((a) => a.type === 'EP'));
}
if (includeSingles) {
selectedReleases = selectedReleases.concat((artist.eps || []).filter((a) => a.type === 'SINGLE'));
}
triggerBtn.disabled = true;
const originalHTML = triggerBtn.innerHTML;
triggerBtn.innerHTML = `${SVG_ANIMATE_SPIN(20)}<span>Downloading...</span>`;
try {
const { downloadDiscography } = await loadDownloadsModule();
await downloadDiscography(artist, selectedReleases, api, quality, lyricsManager);
} catch (error) {
console.error('Discography download failed:', error);
alert('Failed to download discography: ' + error.message);
} finally {
triggerBtn.disabled = false;
triggerBtn.innerHTML = originalHTML;
}
};
modal.classList.add('active');
}
function showKeyboardShortcuts() {
const modal = document.getElementById('shortcuts-modal');
const closeModal = () => {
modal.classList.remove('active');
modal.removeEventListener('click', handleClose);
};
const handleClose = (e) => {
if (
e.target === modal ||
e.target.classList.contains('close-shortcuts') ||
e.target.classList.contains('modal-overlay')
) {
closeModal();
}
};
modal.addEventListener('click', handleClose);
modal.classList.add('active');
}
function showCustomizeShortcutsModal() {
const modal = document.getElementById('customize-shortcuts-modal');
const shortcutsList = document.getElementById('shortcuts-list');
let recordingAction = null;
let recordingTimeout = null;
const formatKey = (key) => {
if (!key) return 'none';
const keyMap = {
' ': 'Space',
arrowup: '↑',
arrowdown: '↓',
arrowleft: '←',
arrowright: '→',
escape: 'Esc',
backspace: 'Backspace',
delete: 'Delete',
insert: 'Insert',
home: 'Home',
end: 'End',
pageup: 'Page Up',
pagedown: 'Page Down',
'[': '[',
']': ']',
'\\': '\\',
tab: 'Tab',
enter: 'Enter',
capslock: 'Caps Lock',
shift: 'Shift',
control: 'Ctrl',
alt: 'Alt',
meta: 'Meta',
contextmenu: 'Context Menu',
};
return keyMap[key.toLowerCase()] || key.toUpperCase();
};
const renderShortcuts = () => {
shortcutsList.innerHTML = '';
const currentShortcuts = keyboardShortcuts.getShortcuts();
for (const [action, shortcut] of Object.entries(currentShortcuts || {})) {
const item = document.createElement('div');
item.className = 'customize-shortcut-item';
item.dataset.action = action;
const modifiers = [];
if (shortcut?.shift) modifiers.push('Shift');
if (shortcut?.ctrl) modifiers.push('Ctrl');
if (shortcut?.alt) modifiers.push('Alt');
const keyDisplay = [...modifiers, formatKey(shortcut?.key)].join(' + ');
item.innerHTML = `
<span class="shortcut-description">${shortcut?.description || 'Unknown'}</span>
<div class="shortcut-key">
<kbd class="${recordingAction === action ? 'recording' : ''}">${keyDisplay}</kbd>
<button class="shortcut-btn" title="Reset to default">
${SVG_RESET(16)}
</button>
</div>
`;
const kbd = item.querySelector('kbd');
kbd.addEventListener('click', (e) => {
e.stopPropagation();
if (recordingAction === action) {
recordingAction = null;
clearTimeout(recordingTimeout);
} else {
recordingAction = action;
recordingTimeout = setTimeout(() => {
keyboardShortcuts.setShortcut(action, {
key: null,
shift: false,
ctrl: false,
alt: false,
description: shortcut?.description || 'Unknown',
});
recordingAction = null;
renderShortcuts();
}, 3000);
}
renderShortcuts();
});
const resetBtn = item.querySelector('.shortcut-btn');
resetBtn.addEventListener('click', (e) => {
e.stopPropagation();
const defaults = keyboardShortcuts.getDefaultShortcuts();
keyboardShortcuts.setShortcut(action, defaults[action]);
renderShortcuts();
});
shortcutsList.appendChild(item);
}
};
const handleKeyDown = (e) => {
if (!recordingAction) return;
e.preventDefault();
e.stopPropagation();
const key = e.key === ' ' ? ' ' : e.key;
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) {
return;
}
keyboardShortcuts.setShortcut(recordingAction, {
key: key,
shift: e.shiftKey,
ctrl: e.ctrlKey || e.metaKey,
alt: e.altKey,
});
clearTimeout(recordingTimeout);
recordingAction = null;
renderShortcuts();
};
const closeModal = () => {
modal.classList.remove('active');
recordingAction = null;
clearTimeout(recordingTimeout);
document.removeEventListener('keydown', handleKeyDown);
modal.removeEventListener('click', handleClose);
};
const handleClose = (e) => {
if (
e.target === modal ||
e.target.classList.contains('close-customize-shortcuts') ||
e.target.id === 'close-customize-shortcuts-btn' ||
e.target.classList.contains('modal-overlay')
) {
closeModal();
}
};
document.getElementById('reset-shortcuts-btn')?.addEventListener('click', () => {
keyboardShortcuts.resetShortcuts();
renderShortcuts();
});
document.addEventListener('keydown', handleKeyDown);
modal.addEventListener('click', handleClose);
renderShortcuts();
modal.classList.add('active');
}