3300 lines
140 KiB
JavaScript
3300 lines
140 KiB
JavaScript
//js/app.js
|
||
import { isIos, isSafari } from './platform-detection.js';
|
||
import { hapticLight } from './haptics.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';
|
||
import { HiFiClient } from './HiFi.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;
|
||
|
||
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');
|
||
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
||
UIRenderer.instance.visualizer.presets['butterchurn'].nextPreset();
|
||
}
|
||
},
|
||
visualizerPrev: () => {
|
||
trackKeyboardShortcut('VisualizerPrev');
|
||
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
||
UIRenderer.instance.visualizer.presets['butterchurn'].prevPreset();
|
||
}
|
||
},
|
||
visualizerCycle: () => {
|
||
trackKeyboardShortcut('VisualizerCycle');
|
||
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
|
||
UIRenderer.instance.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();
|
||
|
||
if (import.meta.env.DEV) {
|
||
window.monochrome = {
|
||
HiFiClient,
|
||
LyricsManager,
|
||
MusicAPI,
|
||
Player,
|
||
UIRenderer,
|
||
};
|
||
}
|
||
|
||
// Haptic feedback on every click
|
||
document.addEventListener('click', () => hapticLight(), { capture: true });
|
||
|
||
// Initialize analytics
|
||
initAnalytics();
|
||
|
||
new ThemeStore();
|
||
await HiFiClient.initialize();
|
||
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);
|
||
removeHiRes(downloadQualitySelect);
|
||
|
||
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);
|
||
|
||
await UIRenderer.initialize(MusicAPI.instance, Player.instance);
|
||
|
||
/**
|
||
* 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);
|
||
UIRenderer.instance.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);
|
||
UIRenderer.instance.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.
|
||
UIRenderer.instance.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 || '')
|
||
);
|
||
UIRenderer.instance.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 = await LyricsManager.initialize(MusicAPI.instance);
|
||
UIRenderer.instance.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 UIRenderer.instance.renderPinnedItems();
|
||
|
||
// Load settings module and initialize
|
||
const { initializeSettings } = await loadSettingsModule();
|
||
await initializeSettings(scrobbler, Player.instance, MusicAPI.instance, UIRenderer.instance);
|
||
|
||
// 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, UIRenderer.instance);
|
||
initializeTrackInteractions(
|
||
Player.instance,
|
||
MusicAPI.instance,
|
||
document.querySelector('.main-content'),
|
||
document.getElementById('context-menu'),
|
||
lyricsManager,
|
||
UIRenderer.instance,
|
||
scrobbler
|
||
);
|
||
initializeUIInteractions(Player.instance, MusicAPI.instance, UIRenderer.instance);
|
||
initializeKeyboardShortcuts(Player.instance, audioPlayer);
|
||
|
||
// Restore UI state for the current track (like button, theme)
|
||
if (Player.instance.currentTrack) {
|
||
UIRenderer.instance.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 {
|
||
UIRenderer.instance.closeFullscreenCover();
|
||
}
|
||
} else {
|
||
const nextTrack = Player.instance.getNextTrack();
|
||
UIRenderer.instance.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 {
|
||
UIRenderer.instance.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 {
|
||
UIRenderer.instance.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 (UIRenderer.instance && typeof UIRenderer.instance.setupUIToggleButton === 'function') {
|
||
if (UIRenderer.instance.uiToggleCleanup) {
|
||
UIRenderer.instance.uiToggleCleanup();
|
||
}
|
||
UIRenderer.instance.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 {
|
||
UIRenderer.instance.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',
|
||
UIRenderer.instance
|
||
);
|
||
}
|
||
});
|
||
|
||
// 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
|
||
UIRenderer.instance.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();
|
||
UIRenderer.instance.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();
|
||
UIRenderer.instance.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');
|
||
UIRenderer.instance.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');
|
||
UIRenderer.instance.renderLibraryPage();
|
||
// Also update current page if we are on it
|
||
if (window.location.pathname === `/userplaylist/${editingId}`) {
|
||
UIRenderer.instance.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);
|
||
UIRenderer.instance.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');
|
||
UIRenderer.instance.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 UIRenderer.instance.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);
|
||
UIRenderer.instance.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');
|
||
|
||
UIRenderer.instance.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) {
|
||
UIRenderer.instance.addToSearchHistory(query);
|
||
}
|
||
});
|
||
|
||
searchInput.addEventListener('focus', () => {
|
||
UIRenderer.instance.renderSearchHistory();
|
||
});
|
||
|
||
searchInput.addEventListener('click', () => {
|
||
UIRenderer.instance.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)) {
|
||
UIRenderer.instance.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(UIRenderer.instance);
|
||
|
||
const handleRouteChange = async (event) => {
|
||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||
const isFullscreenOpen = overlay && getComputedStyle(overlay).display === 'flex';
|
||
|
||
if (isFullscreenOpen && window.location.hash !== '#fullscreen') {
|
||
UIRenderer.instance.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') {
|
||
UIRenderer.instance.renderLibraryPage();
|
||
} else if (path === '/' || path === '/home') {
|
||
UIRenderer.instance.renderHomePage();
|
||
} else if (path.startsWith('/userplaylist/')) {
|
||
const playlistId = path.split('/')[2];
|
||
const content = document.querySelector('.main-content');
|
||
const scroll = content ? content.scrollTop : 0;
|
||
UIRenderer.instance.renderPlaylistPage(playlistId, 'user').then(() => {
|
||
if (content) content.scrollTop = scroll;
|
||
});
|
||
}
|
||
});
|
||
window.addEventListener('history-changed', () => {
|
||
const path = window.location.pathname;
|
||
if (path === '/recent') {
|
||
UIRenderer.instance.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');
|
||
}
|