From 2a708e2b99abe83314764c0600f284bfe1fc0473 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Sun, 19 Oct 2025 18:33:41 +0300 Subject: [PATCH] themes! --- index.html | 240 ++++++++++++++------------ js/api.js | 27 +-- js/app.js | 373 +++++++++++++++++++++++----------------- js/cache.js | 1 - js/metadata.js | 210 +++++++++++++++++++++++ js/player.js | 146 ++++++++++++++-- js/storage.js | 245 ++++++++++++++++++++++++-- js/ui.js | 58 ++++--- styles.css | 456 ++++++++++++++++++++++++++++++++++++++++++------- 9 files changed, 1339 insertions(+), 417 deletions(-) create mode 100644 js/metadata.js diff --git a/index.html b/index.html index 3a66d0c..d50b988 100644 --- a/index.html +++ b/index.html @@ -17,8 +17,6 @@ - - @@ -37,6 +35,11 @@
+
+ +
@@ -54,33 +57,34 @@
@@ -163,14 +175,16 @@
Artist

- +
+ +
@@ -186,10 +200,33 @@

Settings

+
+
+ Theme + Choose your preferred color scheme +
+
+
+
Black
+
Dark
+
Ocean
+
Purple
+
Forest
+
Custom
+
+
+

Custom Theme

+
+
+ + +
+
+
Audio Quality - Quality for streaming and downloads. + Quality for streaming and downloads
+
+
Gapless Playback - Play audio without interruption between tracks. + Play audio without interruption between tracks
+ +
+

About Monochrome

+
+

+ Monochrome is a lightweight, privacy-focused music streaming client designed for high-fidelity audio playback. + Built with modern web technologies, it provides a clean, distraction-free listening experience. +

+
+

Features

+
    +
  • High-quality lossless audio streaming
  • +
  • Intelligent API caching for improved performance
  • +
  • Offline-capable Progressive Web App (PWA)
  • +
  • Media Session API integration for system controls
  • +
  • Queue management with shuffle and repeat modes
  • +
  • Track downloads with automatic metadata embedding
  • +
  • Multiple API instance support with failover
  • +
  • Dark, minimalist interface optimized for focus
  • +
  • Customizable themes and crossfade support
  • +
-
-
-
- About Monochrome - A minimalist, open-source music streaming application -
-
-
-

- Monochrome is a lightweight, privacy-focused music streaming client designed for high-fidelity audio playback. - Built with modern web technologies, it provides a clean, distraction-free listening experience. -

-
-

Features

-
    -
  • High-quality lossless audio streaming
  • -
  • Intelligent API caching for improved performance
  • -
  • Offline-capable Progressive Web App (PWA)
  • -
  • Media Session API integration for system controls
  • -
  • Queue management with shuffle and repeat modes
  • -
  • Track downloads with automatic metadata embedding
  • -
  • Multiple API instance support with failover
  • -
  • Dark, minimalist interface optimized for focus
  • -
-
-
-

Technology Stack

-

Vanilla JavaScript • ES6 Modules • IndexedDB • Service Workers • Media Session API

-
- -
+
+

Technology Stack

+

Vanilla JavaScript • ES6 Modules • IndexedDB • Service Workers • Media Session API

+
+ +
@@ -297,34 +341,15 @@
+ +
@@ -337,14 +362,7 @@
diff --git a/js/api.js b/js/api.js index 0da9209..aa45218 100644 --- a/js/api.js +++ b/js/api.js @@ -1,6 +1,5 @@ import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js'; import { APICache } from './cache.js'; -import { MetadataEmbedder } from './metadata.js'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; @@ -12,7 +11,6 @@ export class LosslessAPI { ttl: 1000 * 60 * 30 }); this.streamCache = new Map(); - this.metadataEmbedder = new MetadataEmbedder(); setInterval(() => { this.cache.clearExpired(); @@ -29,7 +27,7 @@ export class LosslessAPI { } async fetchWithRetry(relativePath, options = {}) { - const instances = this.settings.getInstances(); + const instances = await this.settings.getInstances(); if (instances.length === 0) { throw new Error("No API instances configured."); } @@ -207,7 +205,7 @@ export class LosslessAPI { return parsed.urls[0]; } } catch { - const match = decoded.match(/https?:\/\/[\w\-.~:?#[```@!$&'()*+,;=%/]+/); + const match = decoded.match(/https?:\/\/[\w\-.~:?#[@!$&'()*+,;=%/]+/); return match ? match[0] : null; } } catch (error) { @@ -401,7 +399,7 @@ export class LosslessAPI { } async downloadTrack(id, quality = 'LOSSLESS', filename, options = {}) { - const { onProgress, embedMetadata = true, track, coverUrl } = options; + const { onProgress } = options; try { const lookup = await this.getTrack(id, quality); @@ -450,24 +448,7 @@ export class LosslessAPI { } } - let blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' }); - - if (embedMetadata && track && quality === 'LOSSLESS' && coverUrl) { - if (onProgress) { - onProgress({ stage: 'metadata', progress: 0 }); - } - - try { - blob = await this.metadataEmbedder.embedMetadata(blob, track, coverUrl, (progress) => { - if (onProgress) { - onProgress({ stage: 'metadata', progress }); - } - }); - } catch (metaError) { - console.warn('Metadata embedding failed, downloading without metadata:', metaError); - } - } - + const blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' }); this.triggerDownload(blob, filename); } else { const blob = await response.blob(); diff --git a/js/app.js b/js/app.js index c72851b..11f4acf 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,5 @@ import { LosslessAPI } from './api.js'; -import { apiSettings } from './storage.js'; +import { apiSettings, themeManager } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; import { @@ -26,16 +26,6 @@ function createDownloadNotification() { if (!downloadNotificationContainer) { downloadNotificationContainer = document.createElement('div'); downloadNotificationContainer.id = 'download-notifications'; - downloadNotificationContainer.style.cssText = ` - position: fixed; - bottom: 120px; - right: 20px; - z-index: 9999; - max-width: 350px; - display: flex; - flex-direction: column; - gap: 0.5rem; - `; document.body.appendChild(downloadNotificationContainer); } return downloadNotificationContainer; @@ -47,14 +37,6 @@ function addDownloadTask(trackId, track, filename, api) { const taskEl = document.createElement('div'); taskEl.className = 'download-task'; taskEl.dataset.trackId = trackId; - taskEl.style.cssText = ` - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - animation: slideIn 0.3s ease; - `; taskEl.innerHTML = `
@@ -111,11 +93,6 @@ function updateDownloadProgress(trackId, progress) { : '?'; statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`; - } else if (progress.stage === 'metadata') { - const percent = Math.round(progress.progress * 100); - progressFill.style.width = `${percent}%`; - progressFill.style.background = '#a855f7'; - statusEl.textContent = `Embedding metadata: ${percent}%`; } } @@ -170,10 +147,7 @@ function removeDownloadTask(trackId) { }, 300); } -async function downloadTrackBlob(track, quality, api, coverUrl = null) { - console.log('[Download] Starting download for:', track.title, 'Quality:', quality); - console.log('[Download] Cover URL:', coverUrl); - +async function downloadTrackBlob(track, quality, api) { const lookup = await api.getTrack(track.id, quality); let streamUrl; @@ -186,28 +160,12 @@ async function downloadTrackBlob(track, quality, api, coverUrl = null) { } } - console.log('[Download] Fetching from:', streamUrl); const response = await fetch(streamUrl); if (!response.ok) { throw new Error(`Failed to fetch track: ${response.status}`); } - let blob = await response.blob(); - console.log('[Download] Downloaded blob size:', blob.size, 'type:', blob.type); - - if (quality === 'LOSSLESS' && coverUrl) { - console.log('[Download] Attempting to embed metadata...'); - try { - const processedBlob = await api.metadataEmbedder.embedMetadata(blob, track, coverUrl, null); - console.log('[Download] Metadata embedded. New size:', processedBlob.size); - blob = processedBlob; - } catch (error) { - console.error('[Download] Metadata embedding failed:', error); - } - } else { - console.log('[Download] Skipping metadata - Quality:', quality, 'Has cover:', !!coverUrl); - } - + const blob = await response.blob(); return blob; } @@ -219,8 +177,6 @@ async function downloadAlbumAsZip(album, tracks, api, quality) { const albumTitle = sanitizeForFilename(album.title || 'Unknown Album'); const folderName = `${albumTitle} - ${artistName} - monochrome.tf`; - const coverUrl = album.cover ? api.getCoverUrl(album.cover, '1280') : null; - const notification = createBulkDownloadNotification('album', album.title, tracks.length); try { @@ -230,7 +186,7 @@ async function downloadAlbumAsZip(album, tracks, api, quality) { updateBulkDownloadProgress(notification, i, tracks.length, track.title); - const blob = await downloadTrackBlob(track, quality, api, coverUrl); + const blob = await downloadTrackBlob(track, quality, api); zip.file(`${folderName}/${filename}`, blob); } @@ -279,11 +235,9 @@ async function downloadDiscography(artist, api, quality) { const albumTitle = sanitizeForFilename(fullAlbum.title || 'Unknown Album'); const albumFolder = `${rootFolder}/${albumTitle}`; - const coverUrl = fullAlbum.cover ? api.getCoverUrl(fullAlbum.cover, '1280') : null; - for (const track of tracks) { const filename = buildTrackFilename(track, quality); - const blob = await downloadTrackBlob(track, quality, api, coverUrl); + const blob = await downloadTrackBlob(track, quality, api); zip.file(`${albumFolder}/${filename}`, blob); } } catch (error) { @@ -320,14 +274,6 @@ function createBulkDownloadNotification(type, name, totalItems) { const notifEl = document.createElement('div'); notifEl.className = 'download-task bulk-download'; - notifEl.style.cssText = ` - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - animation: slideIn 0.3s ease; - `; notifEl.innerHTML = `
@@ -383,59 +329,7 @@ function completeBulkDownload(notifEl, success = true, message = null) { } } -const style = document.createElement('style'); -style.textContent = ` - @keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } - } - - @keyframes slideOut { - from { - transform: translateX(0); - opacity: 1; - } - to { - transform: translateX(100%); - opacity: 0; - } - } - - @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } - } - - .animate-spin { - animation: spin 1s linear infinite; - } - - .download-cancel:hover { - background: var(--secondary) !important; - color: var(--foreground) !important; - } - - .now-playing-bar .title, - .now-playing-bar .artist { - cursor: pointer; - transition: color 0.2s; - } - - .now-playing-bar .title:hover, - .now-playing-bar .artist:hover { - color: var(--highlight); - text-decoration: underline; - } -`; -document.head.appendChild(style); - -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { const api = new LosslessAPI(apiSettings); const ui = new UIRenderer(api); @@ -443,6 +337,13 @@ document.addEventListener('DOMContentLoaded', () => { const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS'; const player = new Player(audioPlayer, api, currentQuality); + const savedCrossfade = localStorage.getItem('crossfade-enabled') === 'true'; + const savedCrossfadeDuration = parseInt(localStorage.getItem('crossfade-duration') || '5'); + player.setCrossfade(savedCrossfade, savedCrossfadeDuration); + + const currentTheme = themeManager.getTheme(); + themeManager.setTheme(currentTheme); + const mainContent = document.querySelector('.main-content'); const playPauseBtn = document.querySelector('.play-pause-btn'); const nextBtn = document.getElementById('next-btn'); @@ -468,6 +369,104 @@ document.addEventListener('DOMContentLoaded', () => { const hamburgerBtn = document.getElementById('hamburger-btn'); let contextTrack = null; + let draggedQueueIndex = null; + + const themePicker = document.getElementById('theme-picker'); + themePicker.querySelectorAll('.theme-option').forEach(option => { + if (option.dataset.theme === currentTheme) { + option.classList.add('active'); + } + + option.addEventListener('click', () => { + const theme = option.dataset.theme; + + themePicker.querySelectorAll('.theme-option').forEach(opt => opt.classList.remove('active')); + option.classList.add('active'); + + if (theme === 'custom') { + document.getElementById('custom-theme-editor').classList.add('show'); + renderCustomThemeEditor(); + } else { + document.getElementById('custom-theme-editor').classList.remove('show'); + themeManager.setTheme(theme); + } + }); + }); + document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('refresh-speed-test-btn'); + const originalText = btn.textContent; + btn.textContent = 'Testing...'; + btn.disabled = true; + + try { + await apiSettings.refreshSpeedTests(); + ui.renderApiSettings(); + btn.textContent = 'Done!'; + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + }, 1500); + } catch (error) { + console.error('Failed to refresh speed tests:', error); + btn.textContent = 'Error'; + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + }, 1500); + } +}); + function renderCustomThemeEditor() { + const grid = document.getElementById('theme-color-grid'); + const customTheme = themeManager.getCustomTheme() || { + background: '#000000', + foreground: '#fafafa', + primary: '#ffffff', + secondary: '#27272a', + muted: '#27272a', + border: '#27272a', + highlight: '#ffffff' + }; + + grid.innerHTML = Object.entries(customTheme).map(([key, value]) => ` +
+ + +
+ `).join(''); + } + + document.getElementById('apply-custom-theme')?.addEventListener('click', () => { + const colors = {}; + document.querySelectorAll('#theme-color-grid input[type="color"]').forEach(input => { + colors[input.dataset.color] = input.value; + }); + themeManager.setCustomTheme(colors); + }); + + document.getElementById('reset-custom-theme')?.addEventListener('click', () => { + renderCustomThemeEditor(); + }); + + const crossfadeToggle = document.getElementById('crossfade-toggle'); + const crossfadeDurationSetting = document.getElementById('crossfade-duration-setting'); + const crossfadeDurationInput = document.getElementById('crossfade-duration'); + + crossfadeToggle.checked = savedCrossfade; + crossfadeDurationSetting.style.display = savedCrossfade ? 'flex' : 'none'; + crossfadeDurationInput.value = savedCrossfadeDuration; + + crossfadeToggle.addEventListener('change', (e) => { + const enabled = e.target.checked; + localStorage.setItem('crossfade-enabled', enabled); + crossfadeDurationSetting.style.display = enabled ? 'flex' : 'none'; + player.setCrossfade(enabled, parseInt(crossfadeDurationInput.value)); + }); + + crossfadeDurationInput.addEventListener('change', (e) => { + const duration = parseInt(e.target.value); + localStorage.setItem('crossfade-duration', duration); + player.setCrossfade(crossfadeToggle.checked, duration); + }); const qualitySetting = document.getElementById('quality-setting'); if (qualitySetting) { @@ -497,6 +496,26 @@ document.addEventListener('DOMContentLoaded', () => { }); document.addEventListener('click', async (e) => { + if (e.target.closest('#play-album-btn')) { + const btn = e.target.closest('#play-album-btn'); + if (btn.disabled) return; + + const albumId = window.location.hash.split('/')[1]; + if (!albumId) return; + + try { + const { tracks } = await api.getAlbum(albumId); + if (tracks.length > 0) { + player.setQueue(tracks, 0); + shuffleBtn.classList.remove('active'); + player.playTrackFromQueue(); + } + } catch (error) { + console.error('Failed to play album:', error); + alert('Failed to play album: ' + error.message); + } + } + if (e.target.closest('#download-album-btn')) { const btn = e.target.closest('#download-album-btn'); if (btn.disabled) return; @@ -590,12 +609,16 @@ document.addEventListener('DOMContentLoaded', () => { } const html = currentQueue.map((track, index) => { - const isPlaying = index === player.currentQueueIndex && - track.id === (currentQueue[player.currentQueueIndex] || {}).id; + const isPlaying = index === player.currentQueueIndex; return ` -
-
${index + 1}
+
+
+ + + + +
@@ -605,23 +628,89 @@ document.addEventListener('DOMContentLoaded', () => {
${formatTime(track.duration)}
+
`; }).join(''); queueList.innerHTML = html; - queueList.querySelectorAll('.track-item').forEach((item, index) => { - item.addEventListener('click', () => { + queueList.querySelectorAll('.queue-track-item').forEach((item) => { + const index = parseInt(item.dataset.queueIndex); + + item.addEventListener('click', (e) => { + if (e.target.closest('.track-menu-btn')) return; player.playAtIndex(index); - player.updatePlayingTrackIndicator(); renderQueue(); }); + + item.addEventListener('dragstart', (e) => { + draggedQueueIndex = index; + item.style.opacity = '0.5'; + }); + + item.addEventListener('dragend', () => { + item.style.opacity = '1'; + }); + + item.addEventListener('dragover', (e) => { + e.preventDefault(); + }); + + item.addEventListener('drop', (e) => { + e.preventDefault(); + if (draggedQueueIndex !== null && draggedQueueIndex !== index) { + player.moveInQueue(draggedQueueIndex, index); + renderQueue(); + } + }); }); - player.updatePlayingTrackIndicator(); + queueList.querySelectorAll('.track-menu-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const index = parseInt(btn.dataset.trackIndex); + showQueueTrackMenu(e, index); + }); + }); }; + function showQueueTrackMenu(e, trackIndex) { + const menu = document.getElementById('queue-track-menu'); + menu.style.top = `${e.pageY}px`; + menu.style.left = `${e.pageX}px`; + menu.classList.add('show'); + menu.dataset.trackIndex = trackIndex; + + document.addEventListener('click', hideQueueTrackMenu); + } + + function hideQueueTrackMenu() { + const menu = document.getElementById('queue-track-menu'); + menu.classList.remove('show'); + document.removeEventListener('click', hideQueueTrackMenu); + } + + document.getElementById('queue-track-menu').addEventListener('click', (e) => { + e.stopPropagation(); + const action = e.target.dataset.action; + const menu = document.getElementById('queue-track-menu'); + const trackIndex = parseInt(menu.dataset.trackIndex); + + if (action === 'remove') { + player.removeFromQueue(trackIndex); + renderQueue(); + } + + hideQueueTrackMenu(); + }); + mainContent.addEventListener('click', e => { const trackItem = e.target.closest('.track-item'); if (trackItem && !trackItem.dataset.queueIndex) { @@ -642,7 +731,7 @@ document.addEventListener('DOMContentLoaded', () => { mainContent.addEventListener('contextmenu', e => { const trackItem = e.target.closest('.track-item'); - if (trackItem) { + if (trackItem && !trackItem.dataset.queueIndex) { e.preventDefault(); contextTrack = trackDataStore.get(trackItem); @@ -677,15 +766,8 @@ document.addEventListener('DOMContentLoaded', () => { api ); - const coverUrl = contextTrack.album?.cover - ? api.getCoverUrl(contextTrack.album.cover, '1280') - : null; - await api.downloadTrack(contextTrack.id, quality, filename, { signal: abortController.signal, - track: contextTrack, - coverUrl: coverUrl, - embedMetadata: true, onProgress: (progress) => { updateDownloadProgress(contextTrack.id, progress); } @@ -869,55 +951,24 @@ document.addEventListener('DOMContentLoaded', () => { } }); - document.getElementById('api-instance-list').addEventListener('click', e => { + document.getElementById('api-instance-list').addEventListener('click', async e => { const button = e.target.closest('button'); if (!button) return; const li = button.closest('li'); const index = parseInt(li.dataset.index, 10); - const instances = apiSettings.getInstances(); + const instances = await apiSettings.getInstances(); if (button.classList.contains('move-up') && index > 0) { [instances[index], instances[index - 1]] = [instances[index - 1], instances[index]]; } else if (button.classList.contains('move-down') && index < instances.length - 1) { [instances[index], instances[index + 1]] = [instances[index + 1], instances[index]]; - } else if (button.classList.contains('delete-instance')) { - instances.splice(index, 1); } apiSettings.saveInstances(instances); ui.renderApiSettings(); }); - document.getElementById('add-instance-form').addEventListener('submit', e => { - e.preventDefault(); - const input = document.getElementById('custom-instance-input'); - const newUrl = input.value.trim(); - - if (newUrl) { - try { - const url = new URL(newUrl); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new Error('Invalid protocol'); - } - - const instances = apiSettings.getInstances(); - const formattedUrl = newUrl.endsWith('/') ? newUrl.slice(0, -1) : newUrl; - - if (!instances.includes(formattedUrl)) { - instances.push(formattedUrl); - apiSettings.saveInstances(instances); - ui.renderApiSettings(); - input.value = ''; - } else { - alert('This instance is already in the list.'); - } - } catch (error) { - alert('Please enter a valid URL (e.g., https://example.com)'); - } - } - }); - document.getElementById('clear-cache-btn')?.addEventListener('click', async () => { const btn = document.getElementById('clear-cache-btn'); const originalText = btn.textContent; diff --git a/js/cache.js b/js/cache.js index 5070682..a12d00a 100644 --- a/js/cache.js +++ b/js/cache.js @@ -1,4 +1,3 @@ -//js/cache.js export class APICache { constructor(options = {}) { this.memoryCache = new Map(); diff --git a/js/metadata.js b/js/metadata.js new file mode 100644 index 0000000..82e4f77 --- /dev/null +++ b/js/metadata.js @@ -0,0 +1,210 @@ +export class MetadataEmbedder { + constructor() { + this.ffmpegLoaded = false; + this.ffmpeg = null; + this.fetchFile = null; + } + + async loadFFmpeg() { + if (this.ffmpegLoaded) return; + + try { + console.log('[FFmpeg] Loading FFmpeg...'); + + if (typeof FFmpegWASM === 'undefined' || typeof FFmpegUtil === 'undefined') { + throw new Error('FFmpeg libraries not loaded. Please check your internet connection.'); + } + + const { FFmpeg } = FFmpegWASM; + const { fetchFile } = FFmpegUtil; + + this.ffmpeg = new FFmpeg(); + this.fetchFile = fetchFile; + + this.ffmpeg.on('log', ({ message }) => { + console.log('[FFmpeg]', message); + }); + + const baseURL = window.location.origin + '/ffmpeg'; + + await this.ffmpeg.load({ + coreURL: `${baseURL}/ffmpeg-core.js`, + wasmURL: `${baseURL}/ffmpeg-core.wasm` + }); + + this.ffmpegLoaded = true; + console.log('[FFmpeg] Loaded successfully'); + } catch (error) { + console.error('[FFmpeg] Failed to load:', error); + throw error; + } + } + + async embedMetadata(audioBlob, track, coverImageUrl, onProgress) { + console.log('[Metadata] Starting embedding for:', track.title); + + if (!this.ffmpegLoaded) { + try { + await this.loadFFmpeg(); + } catch (error) { + console.error('[Metadata] Cannot load FFmpeg, skipping metadata:', error); + return audioBlob; + } + } + + if (!this.ffmpeg || !this.fetchFile) { + console.error('[Metadata] FFmpeg not properly initialized'); + return audioBlob; + } + + const inputName = 'input.flac'; + const coverName = 'cover.jpg'; + const outputName = 'output.flac'; + + try { + const arrayBuffer = await audioBlob.arrayBuffer(); + await this.ffmpeg.writeFile(inputName, new Uint8Array(arrayBuffer)); + console.log('[Metadata] Wrote input file:', inputName, 'size:', arrayBuffer.byteLength); + + let hasCover = false; + if (coverImageUrl) { + try { + console.log('[Metadata] Fetching cover from:', coverImageUrl); + const coverData = await this.fetchFile(coverImageUrl); + await this.ffmpeg.writeFile(coverName, coverData); + hasCover = true; + console.log('[Metadata] Cover image written successfully, size:', coverData.length); + } catch (coverError) { + console.warn('[Metadata] Failed to fetch cover image:', coverError); + } + } + + const metadata = this.buildMetadataArgs(track); + console.log('[Metadata] Building metadata with', metadata.length / 2, 'fields'); + + let args; + if (hasCover) { + args = [ + '-i', inputName, + '-i', coverName, + '-map', '0:a', + '-map', '1', + '-c:a', 'copy', + '-c:v', 'copy', + ...metadata, + '-metadata:s:v', 'title=Album cover', + '-metadata:s:v', 'comment=Cover (front)', + '-disposition:v', 'attached_pic', + outputName + ]; + } else { + args = [ + '-i', inputName, + ...metadata, + '-c:a', 'copy', + outputName + ]; + } + + console.log('[Metadata] Executing FFmpeg...'); + + if (onProgress) { + this.ffmpeg.on('progress', ({ progress }) => { + onProgress(progress); + }); + } + + await this.ffmpeg.exec(args); + console.log('[Metadata] FFmpeg exec completed successfully'); + + const outputData = await this.ffmpeg.readFile(outputName); + const outputBlob = new Blob([outputData], { type: 'audio/flac' }); + console.log('[Metadata] ✓ Success! Input:', arrayBuffer.byteLength, 'bytes → Output:', outputBlob.size, 'bytes'); + + await this.ffmpeg.deleteFile(inputName); + await this.ffmpeg.deleteFile(outputName); + if (hasCover) { + await this.ffmpeg.deleteFile(coverName); + } + console.log('[Metadata] Cleanup complete'); + + return outputBlob; + } catch (error) { + console.error('[Metadata] ✗ Embedding failed:', error); + console.error('[Metadata] Error details:', { + name: error.name, + message: error.message, + stack: error.stack + }); + return audioBlob; + } + } + + buildMetadataArgs(track) { + const args = []; + + if (track.title) { + args.push('-metadata', `title=${this.escapeMetadata(track.title)}`); + } + + if (track.artist?.name) { + args.push('-metadata', `artist=${this.escapeMetadata(track.artist.name)}`); + } + + if (track.album?.title) { + args.push('-metadata', `album=${this.escapeMetadata(track.album.title)}`); + } + + if (track.album?.artist?.name) { + args.push('-metadata', `album_artist=${this.escapeMetadata(track.album.artist.name)}`); + } + + if (track.trackNumber) { + const trackNum = Number(track.trackNumber); + if (Number.isFinite(trackNum) && trackNum > 0) { + const totalTracks = track.album?.numberOfTracks; + if (totalTracks && Number.isFinite(totalTracks) && totalTracks > 0) { + args.push('-metadata', `track=${trackNum}/${totalTracks}`); + } else { + args.push('-metadata', `track=${trackNum}`); + } + } + } + + if (track.volumeNumber) { + const discNum = Number(track.volumeNumber); + if (Number.isFinite(discNum) && discNum > 0) { + const totalDiscs = track.album?.numberOfVolumes; + if (totalDiscs && Number.isFinite(totalDiscs) && totalDiscs > 0) { + args.push('-metadata', `disc=${discNum}/${totalDiscs}`); + } else { + args.push('-metadata', `disc=${discNum}`); + } + } + } + + if (track.album?.releaseDate) { + const year = new Date(track.album.releaseDate).getFullYear(); + if (!isNaN(year)) { + args.push('-metadata', `date=${year}`); + args.push('-metadata', `year=${year}`); + } + } + + if (track.album?.upc) { + args.push('-metadata', `barcode=${track.album.upc}`); + } + + if (track.isrc) { + args.push('-metadata', `isrc=${track.isrc}`); + } + + args.push('-metadata', 'comment=https://monochrome.tf/'); + + return args; + } + + escapeMetadata(value) { + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } +} \ No newline at end of file diff --git a/js/player.js b/js/player.js index 63d17ad..a39c5b5 100644 --- a/js/player.js +++ b/js/player.js @@ -14,8 +14,23 @@ export class Player { this.preloadCache = new Map(); this.preloadAbortController = null; this.currentTrack = null; + this.crossfadeEnabled = false; + this.crossfadeDuration = 5; + this.nextAudioElement = null; + this.isCrossfading = false; this.setupMediaSession(); + this.setupCrossfade(); + } + + setupCrossfade() { + this.nextAudioElement = document.createElement('audio'); + this.nextAudioElement.preload = 'auto'; + } + + setCrossfade(enabled, duration = 5) { + this.crossfadeEnabled = enabled; + this.crossfadeDuration = Math.max(1, Math.min(12, duration)); } setupMediaSession() { @@ -81,7 +96,7 @@ export class Player { } } - for (const { track } of tracksToPreload) { + for (const { track, index } of tracksToPreload) { if (this.preloadCache.has(track.id)) continue; try { @@ -89,18 +104,11 @@ export class Player { if (this.preloadAbortController.signal.aborted) break; - fetch(streamUrl, { - signal: this.preloadAbortController.signal, - method: 'HEAD', - mode: 'cors', - cache: 'default' - }).then(() => { - this.preloadCache.set(track.id, streamUrl); - }).catch(err => { - if (err.name !== 'AbortError') { - console.debug('Preload failed for:', track.title); - } - }); + this.preloadCache.set(track.id, streamUrl); + + if (index === this.currentQueueIndex + 1 && this.crossfadeEnabled) { + this.nextAudioElement.src = streamUrl; + } } catch (error) { if (error.name !== 'AbortError') { @@ -137,11 +145,23 @@ export class Player { streamUrl = await this.api.getStreamUrl(track.id, this.quality); } - this.audio.src = streamUrl; + if (this.isCrossfading && this.nextAudioElement.src === streamUrl) { + const temp = this.audio; + this.audio = this.nextAudioElement; + this.nextAudioElement = temp; + + this.nextAudioElement.pause(); + this.nextAudioElement.currentTime = 0; + } else { + this.audio.src = streamUrl; + } + await this.audio.play(); + this.isCrossfading = false; this.updateMediaSessionPlaybackState(); this.preloadNextTracks(); + this.setupCrossfadeListener(); } catch (error) { console.error(`Could not play track: ${track.title}`, error); @@ -150,6 +170,66 @@ export class Player { } } + setupCrossfadeListener() { + if (!this.crossfadeEnabled) return; + + const checkCrossfade = () => { + const timeRemaining = this.audio.duration - this.audio.currentTime; + + if (timeRemaining <= this.crossfadeDuration && timeRemaining > 0 && !this.isCrossfading) { + this.startCrossfade(); + } + }; + + this.audio.removeEventListener('timeupdate', this.crossfadeCheck); + this.crossfadeCheck = checkCrossfade; + this.audio.addEventListener('timeupdate', this.crossfadeCheck); + } + + async startCrossfade() { + if (this.repeatMode === REPEAT_MODE.ONE) return; + + const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; + const nextIndex = this.currentQueueIndex + 1; + + if (nextIndex >= currentQueue.length && this.repeatMode !== REPEAT_MODE.ALL) return; + + this.isCrossfading = true; + const targetIndex = nextIndex >= currentQueue.length ? 0 : nextIndex; + const nextTrack = currentQueue[targetIndex]; + + if (this.nextAudioElement.src && this.preloadCache.has(nextTrack.id)) { + try { + await this.nextAudioElement.play(); + this.nextAudioElement.volume = 0; + + const fadeSteps = 20; + const fadeInterval = (this.crossfadeDuration * 1000) / fadeSteps; + + let step = 0; + const fadeTimer = setInterval(() => { + step++; + const progress = step / fadeSteps; + + this.audio.volume = Math.max(0, 1 - progress); + this.nextAudioElement.volume = Math.min(1, progress); + + if (step >= fadeSteps) { + clearInterval(fadeTimer); + this.audio.pause(); + this.audio.volume = 1; + this.currentQueueIndex = targetIndex; + this.playTrackFromQueue(); + } + }, fadeInterval); + + } catch (error) { + console.error('Crossfade failed:', error); + this.isCrossfading = false; + } + } + } + playAtIndex(index) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (index >= 0 && index < currentQueue.length) { @@ -256,6 +336,44 @@ export class Player { } } + removeFromQueue(index) { + const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; + + if (index < 0 || index >= currentQueue.length) return; + + if (this.shuffleActive) { + this.shuffledQueue.splice(index, 1); + } else { + this.queue.splice(index, 1); + } + + if (index < this.currentQueueIndex) { + this.currentQueueIndex--; + } else if (index === this.currentQueueIndex) { + if (currentQueue.length > 0) { + this.playTrackFromQueue(); + } + } + } + + moveInQueue(fromIndex, toIndex) { + const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; + + if (fromIndex < 0 || fromIndex >= currentQueue.length) return; + if (toIndex < 0 || toIndex >= currentQueue.length) return; + + const [track] = currentQueue.splice(fromIndex, 1); + currentQueue.splice(toIndex, 0, track); + + if (this.currentQueueIndex === fromIndex) { + this.currentQueueIndex = toIndex; + } else if (fromIndex < this.currentQueueIndex && toIndex >= this.currentQueueIndex) { + this.currentQueueIndex--; + } else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) { + this.currentQueueIndex++; + } + } + getCurrentQueue() { return this.shuffleActive ? this.shuffledQueue : this.queue; } diff --git a/js/storage.js b/js/storage.js index 4f4ad76..df2a66b 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1,29 +1,193 @@ export const apiSettings = { STORAGE_KEY: 'monochrome-api-instances', - defaultInstances: [ - 'https://frankfurt.monochrome.tf/', - 'https://ohio.monochrome.tf/', - 'https://oregon.monochrome.tf/', - 'https://virginia.monochrome.tf/', - 'https://singapore.monochrome.tf/', - 'https://tokyo.monochrome.tf/', - 'https://hund.qqdl.site', - 'https://katze.qqdl.site', - 'https://maus.qqdl.site', - 'https://vogel.qqdl.site', - 'https://wolf.qqdl.site', - 'https://tidal.401658.xyz' - ], + INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json', + SPEED_TEST_CACHE_KEY: 'monochrome-instance-speeds', + SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60, + defaultInstances: [], + instancesLoaded: false, - getInstances() { + async loadInstancesFromGitHub() { + if (this.instancesLoaded) { + return this.defaultInstances; + } + + try { + const response = await fetch(this.INSTANCES_URL); + if (!response.ok) throw new Error('Failed to fetch instances'); + + const data = await response.json(); + const allInstances = []; + + for (const [provider, config] of Object.entries(data.api)) { + if (config.cors === false && Array.isArray(config.urls)) { + allInstances.push(...config.urls); + } + } + + this.defaultInstances = allInstances; + this.instancesLoaded = true; + + return allInstances; + } catch (error) { + console.error('Failed to load instances from GitHub:', error); + this.defaultInstances = [ + 'https://ohio.monochrome.tf/', + 'https://virginia.monochrome.tf/', + 'https://oregon.monochrome.tf/', + 'https://california.monochrome.tf/', + 'https://frankfurt.monochrome.tf/', + 'https://singapore.monochrome.tf/', + 'https://tokyo.monochrome.tf/', + 'https://jakarta.monochrome.tf/', + 'https://wolf.qqdl.site', + 'https://maus.qqdl.site', + 'https://vogel.qqdl.site', + 'https://katze.qqdl.site', + 'https://hund.qqdl.site', + 'https://tidal.401658.xyz' + ]; + this.instancesLoaded = true; + return this.defaultInstances; + } + }, + + async speedTestInstance(url) { + const testUrl = url.endsWith('/') + ? `${url}search/?s=kanye` + : `${url}/search/?s=kanye`; + + const startTime = performance.now(); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(testUrl, { + signal: controller.signal, + cache: 'no-store' + }); + + clearTimeout(timeout); + + if (!response.ok) { + return { url, speed: Infinity, error: `HTTP ${response.status}` }; + } + + const endTime = performance.now(); + const speed = endTime - startTime; + + return { url, speed, error: null }; + } catch (error) { + return { url, speed: Infinity, error: error.message }; + } + }, + + async runSpeedTests(instances) { + console.log('[SpeedTest] Testing', instances.length, 'instances...'); + + const results = await Promise.all( + instances.map(url => this.speedTestInstance(url)) + ); + + const validResults = results.filter(r => r.speed !== Infinity); + const failedResults = results.filter(r => r.speed === Infinity); + + if (failedResults.length > 0) { + console.log('[SpeedTest] Failed instances:', failedResults.map(r => `${r.url} (${r.error})`)); + } + + validResults.sort((a, b) => a.speed - b.speed); + + console.log('[SpeedTest] Results:', validResults.map(r => `${r.url}: ${r.speed.toFixed(0)}ms`)); + + const sortedInstances = [ + ...validResults.map(r => r.url), + ...failedResults.map(r => r.url) + ]; + + const cacheData = { + timestamp: Date.now(), + speeds: results.reduce((acc, r) => { + acc[r.url] = { speed: r.speed, error: r.error }; + return acc; + }, {}) + }; + + try { + localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(cacheData)); + } catch (e) { + console.warn('[SpeedTest] Failed to cache results'); + } + + return sortedInstances; + }, + + getCachedSpeedTests() { + try { + const cached = localStorage.getItem(this.SPEED_TEST_CACHE_KEY); + if (!cached) return null; + + const data = JSON.parse(cached); + + if (Date.now() - data.timestamp > this.SPEED_TEST_CACHE_DURATION) { + return null; + } + + return data; + } catch (e) { + return null; + } + }, + + sortInstancesByCache(instances, cachedData) { + const speeds = cachedData.speeds; + + const sorted = [...instances].sort((a, b) => { + const speedA = speeds[a]?.speed ?? Infinity; + const speedB = speeds[b]?.speed ?? Infinity; + return speedA - speedB; + }); + + console.log('[SpeedTest] Using cached results (age:', + Math.round((Date.now() - cachedData.timestamp) / 1000 / 60), 'minutes)'); + + return sorted; + }, + + async getInstances() { try { const stored = localStorage.getItem(this.STORAGE_KEY); - return stored ? JSON.parse(stored) : [...this.defaultInstances]; + if (stored) { + return JSON.parse(stored); + } + + const instances = await this.loadInstancesFromGitHub(); + + const cachedSpeedTests = this.getCachedSpeedTests(); + + let sortedInstances; + if (cachedSpeedTests) { + sortedInstances = this.sortInstancesByCache(instances, cachedSpeedTests); + } else { + sortedInstances = await this.runSpeedTests(instances); + } + + this.saveInstances(sortedInstances); + + return sortedInstances; } catch (e) { - return [...this.defaultInstances]; + const instances = await this.loadInstancesFromGitHub(); + return instances; } }, + async refreshSpeedTests() { + const instances = await this.loadInstancesFromGitHub(); + const sortedInstances = await this.runSpeedTests(instances); + this.saveInstances(sortedInstances); + return sortedInstances; + }, + saveInstances(instances) { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances)); } @@ -66,3 +230,50 @@ export const recentActivityManager = { this._add('albums', album); } }; + +export const themeManager = { + STORAGE_KEY: 'monochrome-theme', + CUSTOM_THEME_KEY: 'monochrome-custom-theme', + + defaultThemes: { + monochrome: {}, + dark: {}, + ocean: {}, + purple: {}, + forest: {} + }, + + getTheme() { + try { + return localStorage.getItem(this.STORAGE_KEY) || 'monochrome'; + } catch (e) { + return 'monochrome'; + } + }, + + setTheme(theme) { + localStorage.setItem(this.STORAGE_KEY, theme); + document.documentElement.setAttribute('data-theme', theme); + }, + + getCustomTheme() { + try { + const stored = localStorage.getItem(this.CUSTOM_THEME_KEY); + return stored ? JSON.parse(stored) : null; + } catch (e) { + return null; + } + }, + + setCustomTheme(colors) { + localStorage.setItem(this.CUSTOM_THEME_KEY, JSON.stringify(colors)); + this.applyCustomTheme(colors); + }, + + applyCustomTheme(colors) { + const root = document.documentElement; + for (const [key, value] of Object.entries(colors)) { + root.style.setProperty(`--${key}`, value); + } + } +}; \ No newline at end of file diff --git a/js/ui.js b/js/ui.js index 2ad22f5..e44f0cd 100644 --- a/js/ui.js +++ b/js/ui.js @@ -304,40 +304,46 @@ export class UIRenderer { } renderApiSettings() { - const container = document.getElementById('api-instance-list'); - const instances = this.api.settings.getInstances(); - const defaultInstancesSet = new Set(this.api.settings.defaultInstances); + const container = document.getElementById('api-instance-list'); + this.api.settings.getInstances().then(instances => { + const cachedData = this.api.settings.getCachedSpeedTests(); + const speeds = cachedData?.speeds || {}; - container.innerHTML = instances.map((url, index) => ` -
  • - ${url} -
    - - - ${!defaultInstancesSet.has(url) ? ` - - ` : ''} -
    -
  • - `).join(''); + +
    + + `; + }).join(''); const stats = this.api.getCacheStats(); const cacheInfo = document.getElementById('cache-info'); if (cacheInfo) { cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`; } - } + }); } +} \ No newline at end of file diff --git a/styles.css b/styles.css index 4c2b690..f19e7ef 100644 --- a/styles.css +++ b/styles.css @@ -1,4 +1,4 @@ -:root { +:root[data-theme="monochrome"] { --background: #000; --foreground: #fafafa; --card: #111; @@ -24,6 +24,110 @@ --spacing-2xl: 3rem; } +:root[data-theme="dark"] { + --background: #0a0a0a; + --foreground: #ededed; + --card: #1a1a1a; + --card-foreground: #ededed; + --primary: #3b82f6; + --primary-foreground: #ffffff; + --secondary: #2a2a2a; + --secondary-foreground: #ededed; + --muted: #2a2a2a; + --muted-foreground: #a0a0a0; + --border: #2a2a2a; + --input: #2a2a2a; + --ring: #3b82f6; + --radius: .5rem; + --highlight: #3b82f6; + --active-highlight: #3b82f6; + --explicit-badge: #ef4444; + --spacing-xs: 0.5rem; + --spacing-sm: 0.75rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; +} + +:root[data-theme="ocean"] { + --background: #0c1821; + --foreground: #e0f4ff; + --card: #1b2838; + --card-foreground: #e0f4ff; + --primary: #06b6d4; + --primary-foreground: #0c1821; + --secondary: #1e3a52; + --secondary-foreground: #e0f4ff; + --muted: #1e3a52; + --muted-foreground: #94c5e0; + --border: #1e3a52; + --input: #1e3a52; + --ring: #06b6d4; + --radius: .5rem; + --highlight: #06b6d4; + --active-highlight: #06b6d4; + --explicit-badge: #f43f5e; + --spacing-xs: 0.5rem; + --spacing-sm: 0.75rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; +} + +:root[data-theme="purple"] { + --background: #0f0514; + --foreground: #f3e8ff; + --card: #1e0a2e; + --card-foreground: #f3e8ff; + --primary: #a855f7; + --primary-foreground: #ffffff; + --secondary: #2d1545; + --secondary-foreground: #f3e8ff; + --muted: #2d1545; + --muted-foreground: #c4b5fd; + --border: #2d1545; + --input: #2d1545; + --ring: #a855f7; + --radius: .5rem; + --highlight: #a855f7; + --active-highlight: #a855f7; + --explicit-badge: #ec4899; + --spacing-xs: 0.5rem; + --spacing-sm: 0.75rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; +} + +:root[data-theme="forest"] { + --background: #0a1409; + --foreground: #e8f5e9; + --card: #1a2e1a; + --card-foreground: #e8f5e9; + --primary: #22c55e; + --primary-foreground: #0a1409; + --secondary: #2d4a2d; + --secondary-foreground: #e8f5e9; + --muted: #2d4a2d; + --muted-foreground: #86efac; + --border: #2d4a2d; + --input: #2d4a2d; + --ring: #22c55e; + --radius: .5rem; + --highlight: #22c55e; + --active-highlight: #22c55e; + --explicit-badge: #f59e0b; + --spacing-xs: 0.5rem; + --spacing-sm: 0.75rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; +} + *, *::before, *::after { box-sizing: border-box; margin: 0; @@ -40,6 +144,7 @@ body { color: var(--foreground); font-family: 'Inter', sans-serif; overflow: hidden; + transition: background-color 0.3s ease, color 0.3s ease; } img { @@ -370,6 +475,7 @@ a { border-radius: var(--radius); cursor: pointer; transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; } .track-item:hover { @@ -479,6 +585,45 @@ a { margin-top: 1rem; } +.detail-header-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.875rem 1.75rem; + background-color: var(--primary); + color: var(--primary-foreground); + border: none; + border-radius: 2rem; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.btn-primary:hover { + transform: scale(1.05); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-primary svg { + flex-shrink: 0; +} + .settings-list { max-width: 800px; } @@ -514,6 +659,15 @@ a { padding: 0.5rem; } +.setting-item input[type="number"] { + background-color: var(--input); + color: var(--foreground); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem; + width: 100px; +} + .toggle-switch { position: relative; display: inline-block; @@ -605,6 +759,12 @@ input:checked + .slider:before { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: pointer; + transition: color 0.2s; +} + +.track-info .details .title:hover { + color: var(--highlight); } .track-info .details .artist { @@ -613,6 +773,12 @@ input:checked + .slider:before { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: pointer; + transition: color 0.2s; +} + +.track-info .details .artist:hover { + color: var(--highlight); } .player-controls { @@ -879,6 +1045,88 @@ input:checked + .slider:before { padding: .5rem; } +.queue-track-item { + display: grid; + grid-template-columns: 32px 1fr auto auto; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm); + border-radius: var(--radius); + cursor: grab; + transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); + margin-bottom: 2px; +} + +.queue-track-item:active { + cursor: grabbing; +} + +.queue-track-item:hover { + background-color: var(--secondary); +} + +.queue-track-item.playing { + background-color: var(--secondary); +} + +.queue-track-item .drag-handle { + color: var(--muted-foreground); + display: flex; + align-items: center; + justify-content: center; + cursor: grab; +} + +.queue-track-item .track-menu-btn { + background: transparent; + border: none; + color: var(--muted-foreground); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all .2s; + display: flex; + align-items: center; + justify-content: center; +} + +.queue-track-item .track-menu-btn:hover { + background-color: var(--muted); + color: var(--foreground); +} + +.queue-track-menu { + display: none; + position: absolute; + background-color: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, .5); + z-index: 1001; + min-width: 120px; +} + +.queue-track-menu.show { + display: block; +} + +.queue-track-menu ul { + list-style: none; +} + +.queue-track-menu li { + padding: .5rem .75rem; + cursor: pointer; + border-radius: 4px; + transition: background-color .2s; + font-size: 0.9rem; +} + +.queue-track-menu li:hover { + background-color: var(--secondary); +} + .placeholder-text { padding: 2rem 1rem; color: var(--muted-foreground); @@ -951,35 +1199,6 @@ input:checked + .slider:before { cursor: not-allowed; } -#add-instance-form { - display: flex; - gap: .75rem; -} - -#add-instance-form input { - flex-grow: 1; - padding: .5rem .75rem; - background-color: var(--input); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--foreground); -} - -#add-instance-form button { - padding: .5rem 1rem; - background-color: var(--primary); - color: var(--primary-foreground); - border: none; - border-radius: var(--radius); - cursor: pointer; - font-weight: 500; - transition: opacity .2s; -} - -#add-instance-form button:hover { - opacity: .9; -} - #sidebar-overlay { display: none; position: fixed; @@ -992,12 +1211,6 @@ input:checked + .slider:before { backdrop-filter: blur(2px); } -#about-section { - margin-top: 2rem; - padding-top: 2rem; - border-top: 1px solid var(--border); -} - .about-content { padding: 1rem 0; } @@ -1217,6 +1430,134 @@ input:checked + .slider:before { width: 100%; } +.theme-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.theme-option { + padding: 1rem; + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; + text-align: center; + font-weight: 500; +} + +.theme-option:hover { + border-color: var(--highlight); + background-color: var(--secondary); +} + +.theme-option.active { + border-color: var(--primary); + background-color: var(--primary); + color: var(--primary-foreground); +} + +.custom-theme-editor { + margin-top: 1rem; + padding: 1rem; + background-color: var(--secondary); + border-radius: var(--radius); + display: none; +} + +.custom-theme-editor.show { + display: block; +} + +.theme-color-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.theme-color-input { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.theme-color-input label { + font-size: 0.9rem; + color: var(--muted-foreground); +} + +.theme-color-input input[type="color"] { + width: 100%; + height: 40px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; +} + +.theme-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +#download-notifications { + position: fixed; + bottom: 120px; + right: 20px; + z-index: 9999; + max-width: 350px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.download-task { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + animation: slideIn 0.3s ease; +} + +.download-cancel:hover { + background: var(--secondary) !important; + color: var(--foreground) !important; +} + @media (max-width: 1024px) { .app-container { grid-template-columns: 240px 1fr; @@ -1372,6 +1713,21 @@ input:checked + .slider:before { .setting-item .info { width: 100%; } + + .detail-header-actions { + width: 100%; + } + + .btn-primary { + width: 100%; + } + + #download-notifications { + bottom: 160px; + right: 10px; + left: 10px; + max-width: none; + } } @media (max-width: 480px) { @@ -1392,32 +1748,4 @@ input:checked + .slider:before { padding: var(--spacing-sm) var(--spacing-md); font-size: 0.9rem; } -} -.btn-download { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background-color: var(--primary); - color: var(--primary-foreground); - border: none; - border-radius: var(--radius); - font-weight: 600; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -.btn-download:hover { - opacity: 0.9; - transform: translateY(-1px); -} - -.btn-download:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.btn-download svg { - flex-shrink: 0; } \ No newline at end of file