diff --git a/index.html b/index.html index 724e297..45112b8 100644 --- a/index.html +++ b/index.html @@ -2007,6 +2007,45 @@

Settings

+
+ + + + + + +
@@ -3729,7 +3768,6 @@
- diff --git a/js/app.js b/js/app.js index 78f9661..29fad6f 100644 --- a/js/app.js +++ b/js/app.js @@ -6,24 +6,48 @@ 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 { initializeSettings } from './settings.js'; import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js'; import { initializeUIInteractions } from './ui-interactions.js'; -import { - downloadAlbumAsZip, - downloadDiscography, - downloadPlaylistAsZip, - downloadLikedTracks, - showNotification, -} from './downloads.js'; import { debounce, SVG_PLAY } from './utils.js'; import { sidePanelManager } from './side-panel.js'; import { db } from './db.js'; import { syncManager } from './accounts/pocketbase.js'; import { registerSW } from 'virtual:pwa-register'; import './smooth-scrolling.js'; -import { readTrackMetadata } from './metadata.js'; -import { initTracker } from './tracker.js'; + +// Lazy-loaded modules +let settingsModule = null; +let downloadsModule = null; +let trackerModule = 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 loadTrackerModule() { + if (!trackerModule) { + trackerModule = await import('./tracker.js'); + } + return trackerModule; +} + +async function loadMetadataModule() { + if (!metadataModule) { + metadataModule = await import('./metadata.js'); + } + return metadataModule; +} function initializeCasting(audioPlayer, castBtn) { if (!castBtn) return; @@ -305,10 +329,7 @@ document.addEventListener('DOMContentLoaded', async () => { } } - // Pre-load Kuroshiro for romaji conversion in background (always load so it's ready instantly) - lyricsManager.loadKuroshiro().catch((err) => { - console.warn('Failed to pre-load Kuroshiro:', err); - }); + // Kuroshiro is now loaded on-demand only when needed for Asian text with Romaji mode enabled const currentTheme = themeManager.getTheme(); themeManager.setTheme(currentTheme); @@ -316,7 +337,10 @@ document.addEventListener('DOMContentLoaded', async () => { // Restore sidebar state sidebarSettings.restoreState(); + // Load settings module and initialize + const { initializeSettings } = await loadSettingsModule(); initializeSettings(scrobbler, player, api, ui); + initializePlayerEvents(player, audioPlayer, scrobbler, ui); initializeTrackInteractions( player, @@ -330,6 +354,8 @@ document.addEventListener('DOMContentLoaded', async () => { initializeUIInteractions(player, api, ui); initializeKeyboardShortcuts(player, audioPlayer); + // Load tracker module + const { initTracker } = await loadTrackerModule(); initTracker(player); const castBtn = document.getElementById('cast-btn'); @@ -510,6 +536,7 @@ document.addEventListener('DOMContentLoaded', async () => { } } catch (error) { console.error('Failed to play album:', error); + const { showNotification } = await loadDownloadsModule(); showNotification('Failed to play album'); } } @@ -533,10 +560,12 @@ document.addEventListener('DOMContentLoaded', async () => { if (shuffleBtn) shuffleBtn.classList.remove('active'); player.shuffleActive = false; player.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'); } } @@ -560,6 +589,7 @@ document.addEventListener('DOMContentLoaded', async () => { try { const { mix, tracks } = await api.getMix(mixId); + const { downloadPlaylistAsZip } = await loadDownloadsModule(); await downloadPlaylistAsZip(mix, tracks, api, downloadQualitySettings.getQuality(), lyricsManager); } catch (error) { console.error('Mix download failed:', error); @@ -603,6 +633,7 @@ document.addEventListener('DOMContentLoaded', async () => { tracks = data.tracks; } + const { downloadPlaylistAsZip } = await loadDownloadsModule(); await downloadPlaylistAsZip(playlist, tracks, api, downloadQualitySettings.getQuality(), lyricsManager); } catch (error) { console.error('Playlist download failed:', error); @@ -966,6 +997,7 @@ document.addEventListener('DOMContentLoaded', async () => { try { const { album, tracks } = await api.getAlbum(albumId); + const { downloadAlbumAsZip } = await loadDownloadsModule(); await downloadAlbumAsZip(album, tracks, api, downloadQualitySettings.getQuality(), lyricsManager); } catch (error) { console.error('Album download failed:', error); @@ -987,6 +1019,7 @@ document.addEventListener('DOMContentLoaded', async () => { const { tracks } = await api.getAlbum(albumId); if (!tracks || tracks.length === 0) { + const { showNotification } = await loadDownloadsModule(); showNotification('No tracks found in this album.'); return; } @@ -1044,10 +1077,12 @@ document.addEventListener('DOMContentLoaded', async () => { try { await db.addTracksToPlaylist(playlistId, tracks); + 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.'); } }; @@ -1065,6 +1100,7 @@ document.addEventListener('DOMContentLoaded', async () => { 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.'); } } @@ -1176,6 +1212,7 @@ document.addEventListener('DOMContentLoaded', async () => { alert('No liked tracks to download.'); return; } + const { downloadLikedTracks } = await loadDownloadsModule(); await downloadLikedTracks(likedTracks, api, downloadQualitySettings.getQuality(), lyricsManager); } catch (error) { console.error('Liked tracks download failed:', error); @@ -1235,6 +1272,7 @@ document.addEventListener('DOMContentLoaded', async () => { name.endsWith('.ogg') ) { const file = await entry.getFile(); + const { readTrackMetadata } = await loadMetadataModule(); const metadata = await readTrackMetadata(file); metadata.id = `local-${idCounter++}-${file.name}`; tracks.push(metadata); @@ -1342,6 +1380,7 @@ document.addEventListener('DOMContentLoaded', async () => { } if (event && event.state && event.state.exitTrap) { + const { showNotification } = await loadDownloadsModule(); showNotification('Press back again to exit'); setTimeout(() => { if (history.state && history.state.exitTrap) { @@ -1908,6 +1947,7 @@ function showDiscographyDownloadModal(artist, api, quality, lyricsManager, trigg 'Downloading...'; try { + const { downloadDiscography } = await loadDownloadsModule(); await downloadDiscography(artist, selectedReleases, api, quality, lyricsManager); } catch (error) { console.error('Discography download failed:', error); diff --git a/js/downloads.js b/js/downloads.js index 7a54cee..9122e99 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -417,6 +417,126 @@ async function bulkDownloadToZipStream( } } +// Generate ZIP as blob for browsers without File System Access API (iOS, etc.) +async function bulkDownloadToZipBlob( + tracks, + folderName, + api, + quality, + lyricsManager, + notification, + coverBlob = null, + type = 'playlist', + metadata = null +) { + const { abortController } = bulkDownloadTasks.get(notification); + const signal = abortController.signal; + const { downloadZip } = await loadClientZip(); + + async function* yieldFiles() { + // Add cover if available + if (coverBlob) { + yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; + } + + // Generate playlist files first + const useRelativePaths = playlistSettings.shouldUseRelativePaths(); + + if (playlistSettings.shouldGenerateM3U()) { + const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, + lastModified: new Date(), + input: m3uContent, + }; + } + + if (playlistSettings.shouldGenerateM3U8()) { + const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, + lastModified: new Date(), + input: m3u8Content, + }; + } + + if (playlistSettings.shouldGenerateNFO()) { + const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, + lastModified: new Date(), + input: nfoContent, + }; + } + + if (playlistSettings.shouldGenerateJSON()) { + const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.json`, + lastModified: new Date(), + input: jsonContent, + }; + } + + // For albums, generate CUE file + if (type === 'album' && playlistSettings.shouldGenerateCUE()) { + const audioFilename = `${sanitizeForFilename(folderName)}.flac`; // Assume FLAC for CUE + const cueContent = generateCUE(metadata, tracks, audioFilename); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.cue`, + lastModified: new Date(), + input: cueContent, + }; + } + + // Download tracks + for (let i = 0; i < tracks.length; i++) { + if (signal.aborted) break; + const track = tracks[i]; + const trackTitle = getTrackTitle(track); + + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); + + try { + const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); + const filename = buildTrackFilename(track, quality, extension); + yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob }; + + if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { + try { + const lyricsData = await lyricsManager.fetchLyrics(track.id, track); + if (lyricsData) { + const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); + if (lrcContent) { + const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); + yield { + name: `${folderName}/${lrcFilename}`, + lastModified: new Date(), + input: lrcContent, + }; + } + } + } catch { + /* ignore */ + } + } + } catch (err) { + if (err.name === 'AbortError') throw err; + console.error(`Failed to download track ${trackTitle}:`, err); + } + } + } + + try { + const response = downloadZip(yieldFiles()); + const blob = await response.blob(); + triggerDownload(blob, `${folderName}.zip`); + } catch (error) { + if (error.name === 'AbortError') return; + throw error; + } +} + async function startBulkDownload( tracks, defaultName, @@ -431,9 +551,13 @@ async function startBulkDownload( const notification = createBulkDownloadNotification(type, name, tracks.length); try { - const useZip = window.showSaveFilePicker && !bulkDownloadSettings.shouldForceIndividual(); + const hasFileSystemAccess = + 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype; + const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); + const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); if (useZip) { + // File System Access API available - use streaming try { const fileHandle = await window.showSaveFilePicker({ suggestedName: `${defaultName}.zip`, @@ -459,6 +583,20 @@ async function startBulkDownload( } throw err; } + } else if (useZipBlob) { + // No File System Access API (iOS, etc.) - use blob-based ZIP + await bulkDownloadToZipBlob( + tracks, + defaultName, + api, + quality, + lyricsManager, + notification, + coverBlob, + type, + metadata + ); + completeBulkDownload(notification, true); } else { // Fallback or Forced: Individual sequential downloads await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification); @@ -521,10 +659,127 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const { abortController } = bulkDownloadTasks.get(notification); const signal = abortController.signal; - try { - const useZip = window.showSaveFilePicker && !bulkDownloadSettings.shouldForceIndividual(); + const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype; + const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); + const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); + async function* yieldDiscography() { + for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) { + if (signal.aborted) break; + const album = selectedReleases[albumIndex]; + updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title); + + try { + const { album: fullAlbum, tracks } = await api.getAlbum(album.id); + const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover); + const releaseDateStr = + fullAlbum.releaseDate || + (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); + const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null; + const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : ''; + + const albumFolder = formatTemplate( + localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', + { + albumTitle: fullAlbum.title, + albumArtist: fullAlbum.artist?.name, + year: year, + } + ); + + const fullFolderPath = `${rootFolder}/${albumFolder}`; + if (coverBlob) + yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob }; + + // Generate playlist files for each album + const useRelativePaths = playlistSettings.shouldUseRelativePaths(); + + if (playlistSettings.shouldGenerateM3U()) { + const m3uContent = generateM3U(fullAlbum, tracks, useRelativePaths); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u`, + lastModified: new Date(), + input: m3uContent, + }; + } + + if (playlistSettings.shouldGenerateM3U8()) { + const m3u8Content = generateM3U8(fullAlbum, tracks, useRelativePaths); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u8`, + lastModified: new Date(), + input: m3u8Content, + }; + } + + if (playlistSettings.shouldGenerateNFO()) { + const nfoContent = generateNFO(fullAlbum, tracks, 'album'); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.nfo`, + lastModified: new Date(), + input: nfoContent, + }; + } + + if (playlistSettings.shouldGenerateJSON()) { + const jsonContent = generateJSON(fullAlbum, tracks, 'album'); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.json`, + lastModified: new Date(), + input: jsonContent, + }; + } + + if (playlistSettings.shouldGenerateCUE()) { + const audioFilename = `${sanitizeForFilename(fullAlbum.title)}.flac`; + const cueContent = generateCUE(fullAlbum, tracks, audioFilename); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.cue`, + lastModified: new Date(), + input: cueContent, + }; + } + + for (const track of tracks) { + if (signal.aborted) break; + try { + const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); + const filename = buildTrackFilename(track, quality, extension); + yield { name: `${fullFolderPath}/${filename}`, lastModified: new Date(), input: blob }; + + if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { + try { + const lyricsData = await lyricsManager.fetchLyrics(track.id, track); + if (lyricsData) { + const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); + if (lrcContent) { + const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); + yield { + name: `${fullFolderPath}/${lrcFilename}`, + lastModified: new Date(), + input: lrcContent, + }; + } + } + } catch { + /* ignore */ + } + } + } catch (err) { + if (err.name === 'AbortError') throw err; + console.error(`Failed to download track ${track.title}:`, err); + } + } + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error(`Failed to download album ${album.title}:`, error); + } + } + } + + try { if (useZip) { + // File System Access API available - use streaming const fileHandle = await window.showSaveFilePicker({ suggestedName: `${rootFolder}.zip`, types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], @@ -532,123 +787,16 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const writable = await fileHandle.createWritable(); const { downloadZip } = await loadClientZip(); - async function* yieldDiscography() { - for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) { - if (signal.aborted) break; - const album = selectedReleases[albumIndex]; - updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title); - - try { - const { album: fullAlbum, tracks } = await api.getAlbum(album.id); - const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover); - const releaseDateStr = - fullAlbum.releaseDate || - (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); - const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null; - const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : ''; - - const albumFolder = formatTemplate( - localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', - { - albumTitle: fullAlbum.title, - albumArtist: fullAlbum.artist?.name, - year: year, - } - ); - - const fullFolderPath = `${rootFolder}/${albumFolder}`; - if (coverBlob) - yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob }; - - // Generate playlist files for each album - const useRelativePaths = playlistSettings.shouldUseRelativePaths(); - - if (playlistSettings.shouldGenerateM3U()) { - const m3uContent = generateM3U(fullAlbum, tracks, useRelativePaths); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u`, - lastModified: new Date(), - input: m3uContent, - }; - } - - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8(fullAlbum, tracks, useRelativePaths); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; - } - - if (playlistSettings.shouldGenerateNFO()) { - const nfoContent = generateNFO(fullAlbum, tracks, 'album'); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.nfo`, - lastModified: new Date(), - input: nfoContent, - }; - } - - if (playlistSettings.shouldGenerateJSON()) { - const jsonContent = generateJSON(fullAlbum, tracks, 'album'); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.json`, - lastModified: new Date(), - input: jsonContent, - }; - } - - if (playlistSettings.shouldGenerateCUE()) { - const audioFilename = `${sanitizeForFilename(fullAlbum.title)}.flac`; - const cueContent = generateCUE(fullAlbum, tracks, audioFilename); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.cue`, - lastModified: new Date(), - input: cueContent, - }; - } - - for (const track of tracks) { - if (signal.aborted) break; - try { - const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); - const filename = buildTrackFilename(track, quality, extension); - yield { name: `${fullFolderPath}/${filename}`, lastModified: new Date(), input: blob }; - - if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { - try { - const lyricsData = await lyricsManager.fetchLyrics(track.id, track); - if (lyricsData) { - const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); - if (lrcContent) { - const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); - yield { - name: `${fullFolderPath}/${lrcFilename}`, - lastModified: new Date(), - input: lrcContent, - }; - } - } - } catch { - /* ignore */ - } - } - } catch (err) { - if (err.name === 'AbortError') throw err; - console.error(`Failed to download track ${track.title}:`, err); - } - } - } catch (error) { - if (error.name === 'AbortError') throw error; - console.error(`Failed to download album ${album.title}:`, error); - } - } - } - const response = downloadZip(yieldDiscography()); await response.body.pipeTo(writable); completeBulkDownload(notification, true); + } else if (useZipBlob) { + // No File System Access API (iOS, etc.) - use blob-based ZIP + const { downloadZip } = await loadClientZip(); + const response = downloadZip(yieldDiscography()); + const blob = await response.blob(); + triggerDownload(blob, `${rootFolder}.zip`); + completeBulkDownload(notification, true); } else { // Sequential individual downloads for discography for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) { diff --git a/js/lyrics.js b/js/lyrics.js index 785429b..4dd9c6b 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -3,6 +3,24 @@ import { getTrackTitle, getTrackArtists, buildTrackFilename, SVG_CLOSE } from '. import { sidePanelManager } from './side-panel.js'; const SVG_GENIUS_ACTIVE = ``; + +// Check if text contains Japanese, Chinese, or Korean characters +function containsAsianText(text) { + if (!text) return false; + // Japanese: Hiragana (3040-309F), Katakana (30A0-30FF), Kanji (4E00-9FFF, 3400-4DBF) + // Chinese: CJK Unified Ideographs (4E00-9FFF, 3400-4DBF) + // Korean: Hangul (AC00-D7AF, 1100-11FF, 3130-318F) + const asianRegex = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u3400-\u4DBF\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/; + return asianRegex.test(text); +} + +// Check if track has Asian text in title or artist names +function trackHasAsianText(track) { + if (!track) return false; + const title = track.title || ''; + const artist = getTrackArtists(track) || ''; + return containsAsianText(title) || containsAsianText(artist); +} const SVG_GENIUS_INACTIVE = ``; class GeniusManager { @@ -289,6 +307,11 @@ export class LyricsManager { return this.romajiTextCache.get(text); } + // Only process if text contains Asian characters + if (!containsAsianText(text)) { + return text; + } + // Make sure Kuroshiro is loaded if (!this.kuroshiroLoaded) { const success = await this.loadKuroshiro(); @@ -742,8 +765,9 @@ export class LyricsManager { export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = false) { const manager = lyricsManager || new LyricsManager(); - // Load Kuroshiro in background if needed - if (!manager.kuroshiroLoaded && !manager.kuroshiroLoading) { + // Load Kuroshiro in background only if track has Asian text and Romaji mode is enabled + const isRomajiMode = manager.getRomajiMode(); + if (isRomajiMode && trackHasAsianText(track) && !manager.kuroshiroLoaded && !manager.kuroshiroLoading) { manager.loadKuroshiro().catch((err) => { console.warn('Failed to load Kuroshiro for Romaji conversion:', err); }); @@ -944,8 +968,8 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage // This is critical - observer must be running before lyrics arrive from LRCLIB lyricsManager.setupLyricsObserver(amLyrics); - // If Romaji mode is enabled, ensure Kuroshiro is ready - if (lyricsManager.isRomajiMode && !lyricsManager.kuroshiroLoaded) { + // If Romaji mode is enabled and track has Asian text, ensure Kuroshiro is ready + if (lyricsManager.isRomajiMode && trackHasAsianText(track) && !lyricsManager.kuroshiroLoaded) { await lyricsManager.loadKuroshiro(); } diff --git a/js/settings.js b/js/settings.js index dd006aa..0d00c08 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1237,4 +1237,105 @@ export function initializeSettings(scrobbler, player, api, ui) { } }); } + + // Settings Search functionality + setupSettingsSearch(); +} + +function setupSettingsSearch() { + const searchInput = document.getElementById('settings-search-input'); + if (!searchInput) return; + + // Setup clear button + const clearBtn = searchInput.parentElement.querySelector('.search-clear-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', () => { + searchInput.value = ''; + searchInput.dispatchEvent(new Event('input')); + searchInput.focus(); + }); + } + + // Show/hide clear button based on input + const updateClearButton = () => { + if (clearBtn) { + clearBtn.style.display = searchInput.value ? 'flex' : 'none'; + } + }; + + searchInput.addEventListener('input', () => { + updateClearButton(); + filterSettings(searchInput.value.toLowerCase().trim()); + }); + + searchInput.addEventListener('focus', updateClearButton); +} + +function filterSettings(query) { + const settingsPage = document.getElementById('page-settings'); + if (!settingsPage) return; + + const allTabContents = settingsPage.querySelectorAll('.settings-tab-content'); + const allTabs = settingsPage.querySelectorAll('.settings-tab'); + + if (!query) { + // Reset: show active tab only + allTabContents.forEach((content) => { + content.classList.remove('active'); + }); + allTabs.forEach((tab) => { + tab.classList.remove('active'); + }); + + // Restore first tab as active + const firstTab = allTabs[0]; + const firstContent = allTabContents[0]; + if (firstTab && firstContent) { + firstTab.classList.add('active'); + firstContent.classList.add('active'); + } + + // Show all settings groups and items + const allGroups = settingsPage.querySelectorAll('.settings-group'); + const allItems = settingsPage.querySelectorAll('.setting-item'); + allGroups.forEach((group) => (group.style.display = '')); + allItems.forEach((item) => (item.style.display = '')); + return; + } + + // When searching, show all tabs' content + allTabContents.forEach((content) => { + content.classList.add('active'); + }); + allTabs.forEach((tab) => { + tab.classList.remove('active'); + }); + + // Search through all settings + const allGroups = settingsPage.querySelectorAll('.settings-group'); + + allGroups.forEach((group) => { + const items = group.querySelectorAll('.setting-item'); + let hasMatch = false; + + items.forEach((item) => { + const label = item.querySelector('.label'); + const description = item.querySelector('.description'); + + const labelText = label?.textContent?.toLowerCase() || ''; + const descriptionText = description?.textContent?.toLowerCase() || ''; + + const matches = labelText.includes(query) || descriptionText.includes(query); + + if (matches) { + item.style.display = ''; + hasMatch = true; + } else { + item.style.display = 'none'; + } + }); + + // Show/hide group based on whether it has any visible items + group.style.display = hasMatch ? '' : 'none'; + }); } diff --git a/js/smooth-scrolling.js b/js/smooth-scrolling.js index 1d5aba9..8407c78 100644 --- a/js/smooth-scrolling.js +++ b/js/smooth-scrolling.js @@ -1,13 +1,53 @@ //js/smooth-scrolling.js -/* global Lenis */ import { smoothScrollingSettings } from './storage.js'; let lenis = null; +let lenisLoaded = false; +let lenisLoading = false; -function initializeSmoothScrolling() { +async function loadLenisScript() { + if (lenisLoaded) return true; + if (lenisLoading) { + return new Promise((resolve) => { + const checkLoaded = setInterval(() => { + if (!lenisLoading) { + clearInterval(checkLoaded); + resolve(lenisLoaded); + } + }, 100); + }); + } + + lenisLoading = true; + + try { + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/@studio-freight/lenis'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + + lenisLoaded = true; + lenisLoading = false; + console.log('✓ Lenis loaded successfully'); + return true; + } catch (error) { + console.error('✗ Failed to load Lenis:', error); + lenisLoaded = false; + lenisLoading = false; + return false; + } +} + +async function initializeSmoothScrolling() { if (lenis) return; // Already initialized - lenis = new Lenis({ + const loaded = await loadLenisScript(); + if (!loaded) return; + + lenis = new window.Lenis({ wrapper: document.querySelector('.main-content'), content: document.querySelector('.main-content'), lerp: 0.1, @@ -18,8 +58,10 @@ function initializeSmoothScrolling() { }); function raf(time) { - lenis.raf(time); - requestAnimationFrame(raf); + if (lenis) { + lenis.raf(time); + requestAnimationFrame(raf); + } } requestAnimationFrame(raf); @@ -32,18 +74,18 @@ function destroySmoothScrolling() { } } -function setupSmoothScrolling() { +async function setupSmoothScrolling() { // Check if smooth scrolling is enabled const smoothScrollingEnabled = smoothScrollingSettings.isEnabled(); if (smoothScrollingEnabled) { - initializeSmoothScrolling(); + await initializeSmoothScrolling(); } // Listen for toggle changes - window.addEventListener('smooth-scrolling-toggle', function (e) { + window.addEventListener('smooth-scrolling-toggle', async function (e) { if (e.detail.enabled) { - initializeSmoothScrolling(); + await initializeSmoothScrolling(); } else { destroySmoothScrolling(); } diff --git a/js/storage.js b/js/storage.js index c551ad1..72379df 100644 --- a/js/storage.js +++ b/js/storage.js @@ -54,11 +54,6 @@ export const apiSettings = { console.error('Failed to load instances from GitHub:', error); this.defaultInstances = { api: [ - 'https://ayohh.monochrome.tf', - 'https://seangreengoat.monochrome.tf', - 'https://esdee.monochrome.tf', - 'https://2jewish.monochrome.tf', - 'https://ediddy.monochrome.tf', 'https://arran.monochrome.tf', 'https://api.monochrome.tf', 'https://triton.squid.wtf', @@ -73,11 +68,6 @@ export const apiSettings = { 'https://vogel.qqdl.site', ], streaming: [ - 'https://ayohh.monochrome.tf', - 'https://seangreengoat.monochrome.tf', - 'https://esdee.monochrome.tf', - 'https://2jewish.monochrome.tf', - 'https://ediddy.monochrome.tf', 'https://arran.monochrome.tf', 'https://triton.squid.wtf', 'https://wolf.qqdl.site', diff --git a/js/ui.js b/js/ui.js index 73891c9..124e206 100644 --- a/js/ui.js +++ b/js/ui.js @@ -943,12 +943,91 @@ export class UIRenderer { } }; - progressBar.onclick = (e) => { - const rect = progressBar.getBoundingClientRect(); - const pos = (e.clientX - rect.left) / rect.width; - audioPlayer.currentTime = pos * audioPlayer.duration; + // Progress bar with drag support + let isFsSeeking = false; + let wasFsPlaying = false; + let lastFsSeekPosition = 0; + + const updateFsSeekUI = (position) => { + if (!isNaN(audioPlayer.duration)) { + progressFill.style.width = `${position * 100}%`; + if (currentTimeEl) { + currentTimeEl.textContent = formatTime(position * audioPlayer.duration); + } + } }; + progressBar.addEventListener('mousedown', (e) => { + isFsSeeking = true; + wasFsPlaying = !audioPlayer.paused; + if (wasFsPlaying) audioPlayer.pause(); + + const rect = progressBar.getBoundingClientRect(); + const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + lastFsSeekPosition = pos; + updateFsSeekUI(pos); + }); + + progressBar.addEventListener( + 'touchstart', + (e) => { + e.preventDefault(); + isFsSeeking = true; + wasFsPlaying = !audioPlayer.paused; + if (wasFsPlaying) audioPlayer.pause(); + + const touch = e.touches[0]; + const rect = progressBar.getBoundingClientRect(); + const pos = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); + lastFsSeekPosition = pos; + updateFsSeekUI(pos); + }, + { passive: false } + ); + + document.addEventListener('mousemove', (e) => { + if (isFsSeeking) { + const rect = progressBar.getBoundingClientRect(); + const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + lastFsSeekPosition = pos; + updateFsSeekUI(pos); + } + }); + + document.addEventListener( + 'touchmove', + (e) => { + if (isFsSeeking) { + const touch = e.touches[0]; + const rect = progressBar.getBoundingClientRect(); + const pos = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); + lastFsSeekPosition = pos; + updateFsSeekUI(pos); + } + }, + { passive: false } + ); + + document.addEventListener('mouseup', () => { + if (isFsSeeking) { + if (!isNaN(audioPlayer.duration)) { + audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration; + if (wasFsPlaying) audioPlayer.play(); + } + isFsSeeking = false; + } + }); + + document.addEventListener('touchend', () => { + if (isFsSeeking) { + if (!isNaN(audioPlayer.duration)) { + audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration; + if (wasFsPlaying) audioPlayer.play(); + } + isFsSeeking = false; + } + }); + if (fsLikeBtn) { fsLikeBtn.onclick = () => document.getElementById('now-playing-like-btn')?.click(); } @@ -1063,9 +1142,12 @@ export class UIRenderer { const current = audioPlayer.currentTime || 0; if (duration > 0) { - const percent = (current / duration) * 100; - progressFill.style.width = `${percent}%`; - currentTimeEl.textContent = formatTime(current); + // Only update progress if not currently seeking (user is dragging) + if (!isFsSeeking) { + const percent = (current / duration) * 100; + progressFill.style.width = `${percent}%`; + currentTimeEl.textContent = formatTime(current); + } totalDurationEl.textContent = formatTime(duration); } diff --git a/public/instances.json b/public/instances.json index b83f092..ec20019 100644 --- a/public/instances.json +++ b/public/instances.json @@ -1,10 +1,5 @@ { "api": [ - "https://ayohh.monochrome.tf", - "https://seangreengoat.monochrome.tf", - "https://esdee.monochrome.tf", - "https://2jewish.monochrome.tf", - "https://ediddy.monochrome.tf", "https://arran.monochrome.tf", "https://api.monochrome.tf/", "https://tidal-api.binimum.org", diff --git a/styles.css b/styles.css index 49de3b9..61f1968 100644 --- a/styles.css +++ b/styles.css @@ -2418,6 +2418,11 @@ input:checked + .slider::before { border-radius: 3px; cursor: pointer; position: relative; + transition: height 0.2s ease; +} + +.fullscreen-progress-container .progress-bar:hover { + height: 8px; } .fullscreen-progress-container .progress-fill { @@ -2425,6 +2430,27 @@ input:checked + .slider::before { background: var(--foreground); border-radius: 3px; width: 0%; + transition: width 0.1s ease; + position: relative; + pointer-events: none; +} + +.fullscreen-progress-container .progress-bar:hover .progress-fill { + background: var(--highlight); +} + +.fullscreen-progress-container .progress-bar:hover .progress-fill::after, +.fullscreen-progress-container .progress-bar:active .progress-fill::after { + content: ''; + position: absolute; + right: -6px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background-color: var(--highlight); + border-radius: 50%; + box-shadow: 0 2px 4px rgb(0, 0, 0, 0.3); } .fullscreen-buttons {