remove dead apis, search in settings, playbar dragging
This commit is contained in:
parent
bd45a8cac9
commit
60b60bd8fa
10 changed files with 653 additions and 167 deletions
40
index.html
40
index.html
|
|
@ -2007,6 +2007,45 @@
|
||||||
|
|
||||||
<div id="page-settings" class="page">
|
<div id="page-settings" class="page">
|
||||||
<h2 class="section-title">Settings</h2>
|
<h2 class="section-title">Settings</h2>
|
||||||
|
<form
|
||||||
|
class="track-list-search-container settings-search-container"
|
||||||
|
onsubmit="return false;"
|
||||||
|
style="margin: 1rem 0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="search-icon"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="settings-search-input"
|
||||||
|
placeholder="Search settings..."
|
||||||
|
class="track-list-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-clear-btn btn-icon"
|
||||||
|
title="Clear search"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="settings-tab active" data-tab="appearance">Appearance</button>
|
<button class="settings-tab active" data-tab="appearance">Appearance</button>
|
||||||
<button class="settings-tab" data-tab="interface">Interface</button>
|
<button class="settings-tab" data-tab="interface">Interface</button>
|
||||||
|
|
@ -3729,7 +3768,6 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://unpkg.com/@studio-freight/lenis"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.21.3/dist/pocketbase.umd.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.21.3/dist/pocketbase.umd.js"></script>
|
||||||
<script type="module" src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
68
js/app.js
68
js/app.js
|
|
@ -6,24 +6,48 @@ import { Player } from './player.js';
|
||||||
import { MultiScrobbler } from './multi-scrobbler.js';
|
import { MultiScrobbler } from './multi-scrobbler.js';
|
||||||
import { LyricsManager, openLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
|
import { LyricsManager, openLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
|
||||||
import { createRouter, updateTabTitle, navigate } from './router.js';
|
import { createRouter, updateTabTitle, navigate } from './router.js';
|
||||||
import { initializeSettings } from './settings.js';
|
|
||||||
import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js';
|
import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js';
|
||||||
import { initializeUIInteractions } from './ui-interactions.js';
|
import { initializeUIInteractions } from './ui-interactions.js';
|
||||||
import {
|
|
||||||
downloadAlbumAsZip,
|
|
||||||
downloadDiscography,
|
|
||||||
downloadPlaylistAsZip,
|
|
||||||
downloadLikedTracks,
|
|
||||||
showNotification,
|
|
||||||
} from './downloads.js';
|
|
||||||
import { debounce, SVG_PLAY } from './utils.js';
|
import { debounce, SVG_PLAY } from './utils.js';
|
||||||
import { sidePanelManager } from './side-panel.js';
|
import { sidePanelManager } from './side-panel.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { registerSW } from 'virtual:pwa-register';
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
import './smooth-scrolling.js';
|
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) {
|
function initializeCasting(audioPlayer, castBtn) {
|
||||||
if (!castBtn) return;
|
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)
|
// Kuroshiro is now loaded on-demand only when needed for Asian text with Romaji mode enabled
|
||||||
lyricsManager.loadKuroshiro().catch((err) => {
|
|
||||||
console.warn('Failed to pre-load Kuroshiro:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentTheme = themeManager.getTheme();
|
const currentTheme = themeManager.getTheme();
|
||||||
themeManager.setTheme(currentTheme);
|
themeManager.setTheme(currentTheme);
|
||||||
|
|
@ -316,7 +337,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Restore sidebar state
|
// Restore sidebar state
|
||||||
sidebarSettings.restoreState();
|
sidebarSettings.restoreState();
|
||||||
|
|
||||||
|
// Load settings module and initialize
|
||||||
|
const { initializeSettings } = await loadSettingsModule();
|
||||||
initializeSettings(scrobbler, player, api, ui);
|
initializeSettings(scrobbler, player, api, ui);
|
||||||
|
|
||||||
initializePlayerEvents(player, audioPlayer, scrobbler, ui);
|
initializePlayerEvents(player, audioPlayer, scrobbler, ui);
|
||||||
initializeTrackInteractions(
|
initializeTrackInteractions(
|
||||||
player,
|
player,
|
||||||
|
|
@ -330,6 +354,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initializeUIInteractions(player, api, ui);
|
initializeUIInteractions(player, api, ui);
|
||||||
initializeKeyboardShortcuts(player, audioPlayer);
|
initializeKeyboardShortcuts(player, audioPlayer);
|
||||||
|
|
||||||
|
// Load tracker module
|
||||||
|
const { initTracker } = await loadTrackerModule();
|
||||||
initTracker(player);
|
initTracker(player);
|
||||||
|
|
||||||
const castBtn = document.getElementById('cast-btn');
|
const castBtn = document.getElementById('cast-btn');
|
||||||
|
|
@ -510,6 +536,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to play album:', error);
|
console.error('Failed to play album:', error);
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification('Failed to play album');
|
showNotification('Failed to play album');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -533,10 +560,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
||||||
player.shuffleActive = false;
|
player.shuffleActive = false;
|
||||||
player.playTrackFromQueue();
|
player.playTrackFromQueue();
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification('Shuffling album');
|
showNotification('Shuffling album');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to shuffle album:', error);
|
console.error('Failed to shuffle album:', error);
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification('Failed to shuffle album');
|
showNotification('Failed to shuffle album');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -560,6 +589,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { mix, tracks } = await api.getMix(mixId);
|
const { mix, tracks } = await api.getMix(mixId);
|
||||||
|
const { downloadPlaylistAsZip } = await loadDownloadsModule();
|
||||||
await downloadPlaylistAsZip(mix, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
await downloadPlaylistAsZip(mix, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Mix download failed:', error);
|
console.error('Mix download failed:', error);
|
||||||
|
|
@ -603,6 +633,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
tracks = data.tracks;
|
tracks = data.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { downloadPlaylistAsZip } = await loadDownloadsModule();
|
||||||
await downloadPlaylistAsZip(playlist, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
await downloadPlaylistAsZip(playlist, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Playlist download failed:', error);
|
console.error('Playlist download failed:', error);
|
||||||
|
|
@ -966,6 +997,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { album, tracks } = await api.getAlbum(albumId);
|
const { album, tracks } = await api.getAlbum(albumId);
|
||||||
|
const { downloadAlbumAsZip } = await loadDownloadsModule();
|
||||||
await downloadAlbumAsZip(album, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
await downloadAlbumAsZip(album, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Album download failed:', error);
|
console.error('Album download failed:', error);
|
||||||
|
|
@ -987,6 +1019,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const { tracks } = await api.getAlbum(albumId);
|
const { tracks } = await api.getAlbum(albumId);
|
||||||
|
|
||||||
if (!tracks || tracks.length === 0) {
|
if (!tracks || tracks.length === 0) {
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification('No tracks found in this album.');
|
showNotification('No tracks found in this album.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1044,10 +1077,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.addTracksToPlaylist(playlistId, tracks);
|
await db.addTracksToPlaylist(playlistId, tracks);
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification(`Added ${tracks.length} tracks to playlist.`);
|
showNotification(`Added ${tracks.length} tracks to playlist.`);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification('Failed to add tracks.');
|
showNotification('Failed to add tracks.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1065,6 +1100,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to prepare album for playlist:', error);
|
console.error('Failed to prepare album for playlist:', error);
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification('Failed to load album tracks.');
|
showNotification('Failed to load album tracks.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1176,6 +1212,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
alert('No liked tracks to download.');
|
alert('No liked tracks to download.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { downloadLikedTracks } = await loadDownloadsModule();
|
||||||
await downloadLikedTracks(likedTracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
await downloadLikedTracks(likedTracks, api, downloadQualitySettings.getQuality(), lyricsManager);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Liked tracks download failed:', error);
|
console.error('Liked tracks download failed:', error);
|
||||||
|
|
@ -1235,6 +1272,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
name.endsWith('.ogg')
|
name.endsWith('.ogg')
|
||||||
) {
|
) {
|
||||||
const file = await entry.getFile();
|
const file = await entry.getFile();
|
||||||
|
const { readTrackMetadata } = await loadMetadataModule();
|
||||||
const metadata = await readTrackMetadata(file);
|
const metadata = await readTrackMetadata(file);
|
||||||
metadata.id = `local-${idCounter++}-${file.name}`;
|
metadata.id = `local-${idCounter++}-${file.name}`;
|
||||||
tracks.push(metadata);
|
tracks.push(metadata);
|
||||||
|
|
@ -1342,6 +1380,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event && event.state && event.state.exitTrap) {
|
if (event && event.state && event.state.exitTrap) {
|
||||||
|
const { showNotification } = await loadDownloadsModule();
|
||||||
showNotification('Press back again to exit');
|
showNotification('Press back again to exit');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (history.state && history.state.exitTrap) {
|
if (history.state && history.state.exitTrap) {
|
||||||
|
|
@ -1908,6 +1947,7 @@ function showDiscographyDownloadModal(artist, api, quality, lyricsManager, trigg
|
||||||
'<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
|
'<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { downloadDiscography } = await loadDownloadsModule();
|
||||||
await downloadDiscography(artist, selectedReleases, api, quality, lyricsManager);
|
await downloadDiscography(artist, selectedReleases, api, quality, lyricsManager);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Discography download failed:', error);
|
console.error('Discography download failed:', error);
|
||||||
|
|
|
||||||
382
js/downloads.js
382
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(
|
async function startBulkDownload(
|
||||||
tracks,
|
tracks,
|
||||||
defaultName,
|
defaultName,
|
||||||
|
|
@ -431,9 +551,13 @@ async function startBulkDownload(
|
||||||
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
||||||
|
|
||||||
try {
|
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) {
|
if (useZip) {
|
||||||
|
// File System Access API available - use streaming
|
||||||
try {
|
try {
|
||||||
const fileHandle = await window.showSaveFilePicker({
|
const fileHandle = await window.showSaveFilePicker({
|
||||||
suggestedName: `${defaultName}.zip`,
|
suggestedName: `${defaultName}.zip`,
|
||||||
|
|
@ -459,6 +583,20 @@ async function startBulkDownload(
|
||||||
}
|
}
|
||||||
throw err;
|
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 {
|
} else {
|
||||||
// Fallback or Forced: Individual sequential downloads
|
// Fallback or Forced: Individual sequential downloads
|
||||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
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 { abortController } = bulkDownloadTasks.get(notification);
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
||||||
try {
|
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||||
const useZip = window.showSaveFilePicker && !bulkDownloadSettings.shouldForceIndividual();
|
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) {
|
if (useZip) {
|
||||||
|
// File System Access API available - use streaming
|
||||||
const fileHandle = await window.showSaveFilePicker({
|
const fileHandle = await window.showSaveFilePicker({
|
||||||
suggestedName: `${rootFolder}.zip`,
|
suggestedName: `${rootFolder}.zip`,
|
||||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.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 writable = await fileHandle.createWritable();
|
||||||
const { downloadZip } = await loadClientZip();
|
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());
|
const response = downloadZip(yieldDiscography());
|
||||||
await response.body.pipeTo(writable);
|
await response.body.pipeTo(writable);
|
||||||
completeBulkDownload(notification, true);
|
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 {
|
} else {
|
||||||
// Sequential individual downloads for discography
|
// Sequential individual downloads for discography
|
||||||
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
||||||
|
|
|
||||||
32
js/lyrics.js
32
js/lyrics.js
|
|
@ -3,6 +3,24 @@ import { getTrackTitle, getTrackArtists, buildTrackFilename, SVG_CLOSE } from '.
|
||||||
import { sidePanelManager } from './side-panel.js';
|
import { sidePanelManager } from './side-panel.js';
|
||||||
|
|
||||||
const SVG_GENIUS_ACTIVE = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#ffff64"/><path d="M6.3 6.3h11.4v11.4H6.3z" fill="#000"/></svg>`;
|
const SVG_GENIUS_ACTIVE = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#ffff64"/><path d="M6.3 6.3h11.4v11.4H6.3z" fill="#000"/></svg>`;
|
||||||
|
|
||||||
|
// 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 = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" /><path d="M6.3 6.3h11.4v11.4H6.3z" fill="var(--card)"/></svg>`;
|
const SVG_GENIUS_INACTIVE = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" /><path d="M6.3 6.3h11.4v11.4H6.3z" fill="var(--card)"/></svg>`;
|
||||||
|
|
||||||
class GeniusManager {
|
class GeniusManager {
|
||||||
|
|
@ -289,6 +307,11 @@ export class LyricsManager {
|
||||||
return this.romajiTextCache.get(text);
|
return this.romajiTextCache.get(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only process if text contains Asian characters
|
||||||
|
if (!containsAsianText(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure Kuroshiro is loaded
|
// Make sure Kuroshiro is loaded
|
||||||
if (!this.kuroshiroLoaded) {
|
if (!this.kuroshiroLoaded) {
|
||||||
const success = await this.loadKuroshiro();
|
const success = await this.loadKuroshiro();
|
||||||
|
|
@ -742,8 +765,9 @@ export class LyricsManager {
|
||||||
export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = false) {
|
export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = false) {
|
||||||
const manager = lyricsManager || new LyricsManager();
|
const manager = lyricsManager || new LyricsManager();
|
||||||
|
|
||||||
// Load Kuroshiro in background if needed
|
// Load Kuroshiro in background only if track has Asian text and Romaji mode is enabled
|
||||||
if (!manager.kuroshiroLoaded && !manager.kuroshiroLoading) {
|
const isRomajiMode = manager.getRomajiMode();
|
||||||
|
if (isRomajiMode && trackHasAsianText(track) && !manager.kuroshiroLoaded && !manager.kuroshiroLoading) {
|
||||||
manager.loadKuroshiro().catch((err) => {
|
manager.loadKuroshiro().catch((err) => {
|
||||||
console.warn('Failed to load Kuroshiro for Romaji conversion:', 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
|
// This is critical - observer must be running before lyrics arrive from LRCLIB
|
||||||
lyricsManager.setupLyricsObserver(amLyrics);
|
lyricsManager.setupLyricsObserver(amLyrics);
|
||||||
|
|
||||||
// If Romaji mode is enabled, ensure Kuroshiro is ready
|
// If Romaji mode is enabled and track has Asian text, ensure Kuroshiro is ready
|
||||||
if (lyricsManager.isRomajiMode && !lyricsManager.kuroshiroLoaded) {
|
if (lyricsManager.isRomajiMode && trackHasAsianText(track) && !lyricsManager.kuroshiroLoaded) {
|
||||||
await lyricsManager.loadKuroshiro();
|
await lyricsManager.loadKuroshiro();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
101
js/settings.js
101
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';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,53 @@
|
||||||
//js/smooth-scrolling.js
|
//js/smooth-scrolling.js
|
||||||
/* global Lenis */
|
|
||||||
import { smoothScrollingSettings } from './storage.js';
|
import { smoothScrollingSettings } from './storage.js';
|
||||||
|
|
||||||
let lenis = null;
|
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
|
if (lenis) return; // Already initialized
|
||||||
|
|
||||||
lenis = new Lenis({
|
const loaded = await loadLenisScript();
|
||||||
|
if (!loaded) return;
|
||||||
|
|
||||||
|
lenis = new window.Lenis({
|
||||||
wrapper: document.querySelector('.main-content'),
|
wrapper: document.querySelector('.main-content'),
|
||||||
content: document.querySelector('.main-content'),
|
content: document.querySelector('.main-content'),
|
||||||
lerp: 0.1,
|
lerp: 0.1,
|
||||||
|
|
@ -18,8 +58,10 @@ function initializeSmoothScrolling() {
|
||||||
});
|
});
|
||||||
|
|
||||||
function raf(time) {
|
function raf(time) {
|
||||||
lenis.raf(time);
|
if (lenis) {
|
||||||
requestAnimationFrame(raf);
|
lenis.raf(time);
|
||||||
|
requestAnimationFrame(raf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(raf);
|
requestAnimationFrame(raf);
|
||||||
|
|
@ -32,18 +74,18 @@ function destroySmoothScrolling() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupSmoothScrolling() {
|
async function setupSmoothScrolling() {
|
||||||
// Check if smooth scrolling is enabled
|
// Check if smooth scrolling is enabled
|
||||||
const smoothScrollingEnabled = smoothScrollingSettings.isEnabled();
|
const smoothScrollingEnabled = smoothScrollingSettings.isEnabled();
|
||||||
|
|
||||||
if (smoothScrollingEnabled) {
|
if (smoothScrollingEnabled) {
|
||||||
initializeSmoothScrolling();
|
await initializeSmoothScrolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for toggle changes
|
// Listen for toggle changes
|
||||||
window.addEventListener('smooth-scrolling-toggle', function (e) {
|
window.addEventListener('smooth-scrolling-toggle', async function (e) {
|
||||||
if (e.detail.enabled) {
|
if (e.detail.enabled) {
|
||||||
initializeSmoothScrolling();
|
await initializeSmoothScrolling();
|
||||||
} else {
|
} else {
|
||||||
destroySmoothScrolling();
|
destroySmoothScrolling();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,6 @@ export const apiSettings = {
|
||||||
console.error('Failed to load instances from GitHub:', error);
|
console.error('Failed to load instances from GitHub:', error);
|
||||||
this.defaultInstances = {
|
this.defaultInstances = {
|
||||||
api: [
|
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://arran.monochrome.tf',
|
||||||
'https://api.monochrome.tf',
|
'https://api.monochrome.tf',
|
||||||
'https://triton.squid.wtf',
|
'https://triton.squid.wtf',
|
||||||
|
|
@ -73,11 +68,6 @@ export const apiSettings = {
|
||||||
'https://vogel.qqdl.site',
|
'https://vogel.qqdl.site',
|
||||||
],
|
],
|
||||||
streaming: [
|
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://arran.monochrome.tf',
|
||||||
'https://triton.squid.wtf',
|
'https://triton.squid.wtf',
|
||||||
'https://wolf.qqdl.site',
|
'https://wolf.qqdl.site',
|
||||||
|
|
|
||||||
96
js/ui.js
96
js/ui.js
|
|
@ -943,12 +943,91 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
progressBar.onclick = (e) => {
|
// Progress bar with drag support
|
||||||
const rect = progressBar.getBoundingClientRect();
|
let isFsSeeking = false;
|
||||||
const pos = (e.clientX - rect.left) / rect.width;
|
let wasFsPlaying = false;
|
||||||
audioPlayer.currentTime = pos * audioPlayer.duration;
|
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) {
|
if (fsLikeBtn) {
|
||||||
fsLikeBtn.onclick = () => document.getElementById('now-playing-like-btn')?.click();
|
fsLikeBtn.onclick = () => document.getElementById('now-playing-like-btn')?.click();
|
||||||
}
|
}
|
||||||
|
|
@ -1063,9 +1142,12 @@ export class UIRenderer {
|
||||||
const current = audioPlayer.currentTime || 0;
|
const current = audioPlayer.currentTime || 0;
|
||||||
|
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const percent = (current / duration) * 100;
|
// Only update progress if not currently seeking (user is dragging)
|
||||||
progressFill.style.width = `${percent}%`;
|
if (!isFsSeeking) {
|
||||||
currentTimeEl.textContent = formatTime(current);
|
const percent = (current / duration) * 100;
|
||||||
|
progressFill.style.width = `${percent}%`;
|
||||||
|
currentTimeEl.textContent = formatTime(current);
|
||||||
|
}
|
||||||
totalDurationEl.textContent = formatTime(duration);
|
totalDurationEl.textContent = formatTime(duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
{
|
{
|
||||||
"api": [
|
"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://arran.monochrome.tf",
|
||||||
"https://api.monochrome.tf/",
|
"https://api.monochrome.tf/",
|
||||||
"https://tidal-api.binimum.org",
|
"https://tidal-api.binimum.org",
|
||||||
|
|
|
||||||
26
styles.css
26
styles.css
|
|
@ -2418,6 +2418,11 @@ input:checked + .slider::before {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-progress-container .progress-bar:hover {
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullscreen-progress-container .progress-fill {
|
.fullscreen-progress-container .progress-fill {
|
||||||
|
|
@ -2425,6 +2430,27 @@ input:checked + .slider::before {
|
||||||
background: var(--foreground);
|
background: var(--foreground);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
width: 0%;
|
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 {
|
.fullscreen-buttons {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue