@@ -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('');
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