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">
|
||||
<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">
|
||||
<button class="settings-tab active" data-tab="appearance">Appearance</button>
|
||||
<button class="settings-tab" data-tab="interface">Interface</button>
|
||||
|
|
@ -3729,7 +3768,6 @@
|
|||
</div>
|
||||
</footer>
|
||||
</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 type="module" src="/js/app.js"></script>
|
||||
</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 { 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
|
|||
'<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 {
|
||||
const { downloadDiscography } = await loadDownloadsModule();
|
||||
await downloadDiscography(artist, selectedReleases, api, quality, lyricsManager);
|
||||
} catch (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(
|
||||
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++) {
|
||||
|
|
|
|||
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';
|
||||
|
||||
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>`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
/* 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
96
js/ui.js
96
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
26
styles.css
26
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue