diff --git a/assets/button/880x310.png b/assets/button/880x310.png new file mode 100644 index 0000000..07f4108 Binary files /dev/null and b/assets/button/880x310.png differ diff --git a/assets/button/88x31.png b/assets/button/88x31.png new file mode 100644 index 0000000..717bd98 Binary files /dev/null and b/assets/button/88x31.png differ diff --git a/index.html b/index.html index d50b988..fab5a2e 100644 --- a/index.html +++ b/index.html @@ -222,7 +222,26 @@ - +
+
+ Last.fm Scrobbling + Connect your Last.fm account to scrobble tracks +
+
+ +
+
+ +
Audio Quality @@ -371,7 +390,9 @@
- + + + - \ No newline at end of file + diff --git a/instances.json b/instances.json index dfc5080..485767a 100644 --- a/instances.json +++ b/instances.json @@ -1,6 +1,4 @@ [ - "https://tidal.401658.xyz", - "https://triton.squid.wtf", "https://aether.squid.wtf", "https://zeus.squid.wtf", "https://kraken.squid.wtf", diff --git a/js/api.js b/js/api.js index aa45218..4722f58 100644 --- a/js/api.js +++ b/js/api.js @@ -1,3 +1,4 @@ +//api.js import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js'; import { APICache } from './cache.js'; @@ -308,60 +309,67 @@ export class LosslessAPI { return result; } - async getArtist(id) { - const cached = await this.cache.get('artist', id); - if (cached) return cached; +async getArtist(artistId) { + const cached = await this.cache.get('artist', artistId); + if (cached) return cached; - const [primaryResponse, contentResponse] = await Promise.all([ - this.fetchWithRetry(`/artist/?id=${id}`), - this.fetchWithRetry(`/artist/?f=${id}`) - ]); + const [primaryResponse, contentResponse] = await Promise.all([ + this.fetchWithRetry(`/artist/?id=${artistId}`), + this.fetchWithRetry(`/artist/?f=${artistId}`) + ]); + + const primaryData = await primaryResponse.json(); + const rawArtist = Array.isArray(primaryData) ? primaryData[0] : primaryData; + + if (!rawArtist) throw new Error('Primary artist details not found.'); + + // Ensure artist has required fields + const artist = { + ...this.prepareArtist(rawArtist), + picture: rawArtist.picture || null, + name: rawArtist.name || 'Unknown Artist' + }; + + const contentData = await contentResponse.json(); + const entries = Array.isArray(contentData) ? contentData : [contentData]; + + const albumMap = new Map(); + const trackMap = new Map(); + + const isTrack = v => v?.id && v.duration && v.album; + const isAlbum = v => v?.id && 'numberOfTracks' in v; + + const scan = (value, visited = new Set()) => { + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); - const primaryData = await primaryResponse.json(); - const artist = this.prepareArtist(Array.isArray(primaryData) ? primaryData[0] : primaryData); + if (Array.isArray(value)) { + value.forEach(item => scan(item, visited)); + return; + } - if (!artist) throw new Error('Primary artist details not found.'); + const item = value.item || value; + if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item)); + if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item)); - const contentData = await contentResponse.json(); - const entries = Array.isArray(contentData) ? contentData : [contentData]; - - const albumMap = new Map(); - const trackMap = new Map(); - - const isTrack = v => v?.id && v.duration && v.album; - const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v; - - const scan = (value, visited = new Set()) => { - if (!value || typeof value !== 'object' || visited.has(value)) return; - visited.add(value); - - if (Array.isArray(value)) { - value.forEach(item => scan(item, visited)); - return; - } - - const item = value.item || value; - if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item)); - if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item)); - - Object.values(value).forEach(nested => scan(nested, visited)); - }; - - entries.forEach(entry => scan(entry)); - - const albums = Array.from(albumMap.values()).sort((a, b) => - new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0) - ); - - const tracks = Array.from(trackMap.values()) - .sort((a, b) => (b.popularity || 0) - (a.popularity || 0)) - .slice(0, 10); - - const result = { ...artist, albums, tracks }; + Object.values(value).forEach(nested => scan(nested, visited)); + }; + + entries.forEach(entry => scan(entry)); + + const albums = Array.from(albumMap.values()).sort((a, b) => + new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0) + ); + + const tracks = Array.from(trackMap.values()) + .sort((a, b) => (b.popularity || 0) - (a.popularity || 0)) + .slice(0, 10); + + const result = { ...artist, albums, tracks }; - await this.cache.set('artist', id, result); - return result; - } + await this.cache.set('artist', artistId, result); + return result; +} async getTrack(id, quality = 'LOSSLESS') { const cacheKey = `${id}_${quality}`; diff --git a/js/app.js b/js/app.js index 0165a7f..678bf7b 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,8 @@ import { LosslessAPI } from './api.js'; -import { apiSettings, themeManager } from './storage.js'; +import { apiSettings, themeManager, lastFMStorage } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; +import { LastFMScrobbler } from './lastfm.js'; import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore, @@ -329,6 +330,21 @@ function completeBulkDownload(notifEl, success = true, message = null) { } } +async function loadHomeFeed(api) { + try { + const response = await api.fetchWithRetry('/home/'); + const data = await response.json(); + + if (!Array.isArray(data) || data.length === 0) return null; + + const homeData = data[0]; + return homeData; + } catch (error) { + console.error('Failed to load home feed:', error); + return null; + } +} + document.addEventListener('DOMContentLoaded', async () => { const api = new LosslessAPI(apiSettings); const ui = new UIRenderer(api); @@ -337,6 +353,8 @@ document.addEventListener('DOMContentLoaded', async () => { const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS'; const player = new Player(audioPlayer, api, currentQuality); + const scrobbler = new LastFMScrobbler(); + const savedCrossfade = localStorage.getItem('crossfade-enabled') === 'true'; const savedCrossfadeDuration = parseInt(localStorage.getItem('crossfade-duration') || '5'); player.setCrossfade(savedCrossfade, savedCrossfadeDuration); @@ -371,6 +389,102 @@ document.addEventListener('DOMContentLoaded', async () => { let contextTrack = null; let draggedQueueIndex = null; + const lastfmConnectBtn = document.getElementById('lastfm-connect-btn'); + const lastfmStatus = document.getElementById('lastfm-status'); + const lastfmToggle = document.getElementById('lastfm-toggle'); + const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting'); + + window.loadHomeFeed = loadHomeFeed; + + function updateLastFMUI() { + if (scrobbler.isAuthenticated()) { + lastfmStatus.textContent = `Connected as ${scrobbler.username}`; + lastfmConnectBtn.textContent = 'Disconnect'; + lastfmConnectBtn.classList.add('danger'); + lastfmToggleSetting.style.display = 'flex'; + lastfmToggle.checked = lastFMStorage.isEnabled(); + } else { + lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks'; + lastfmConnectBtn.textContent = 'Connect Last.fm'; + lastfmConnectBtn.classList.remove('danger'); + lastfmToggleSetting.style.display = 'none'; + } + } + + updateLastFMUI(); + + lastfmConnectBtn?.addEventListener('click', async () => { + if (scrobbler.isAuthenticated()) { + if (confirm('Disconnect from Last.fm?')) { + scrobbler.disconnect(); + updateLastFMUI(); + } + return; + } + + const authWindow = window.open('', '_blank'); + + lastfmConnectBtn.disabled = true; + lastfmConnectBtn.textContent = 'Opening Last.fm...'; + + try { + const { token, url } = await scrobbler.getAuthUrl(); + + if (authWindow) { + authWindow.location.href = url; + } else { + alert('Popup blocked! Please allow popups.'); + lastfmConnectBtn.textContent = 'Connect Last.fm'; + lastfmConnectBtn.disabled = false; + return; + } + + lastfmConnectBtn.textContent = 'Waiting for authorization...'; + + let attempts = 0; + const maxAttempts = 30; + + const checkAuth = setInterval(async () => { + attempts++; + + if (attempts > maxAttempts) { + clearInterval(checkAuth); + lastfmConnectBtn.textContent = 'Connect Last.fm'; + lastfmConnectBtn.disabled = false; + if (authWindow && !authWindow.closed) authWindow.close(); + alert('Authorization timed out. Please try again.'); + return; + } + + try { + const result = await scrobbler.completeAuthentication(token); + + if (result.success) { + clearInterval(checkAuth); + if (authWindow && !authWindow.closed) authWindow.close(); + updateLastFMUI(); + lastfmConnectBtn.disabled = false; + lastFMStorage.setEnabled(true); + lastfmToggle.checked = true; + alert(`Successfully connected to Last.fm as ${result.username}!`); + } + } catch (e) { + } + }, 2000); + + } catch (error) { + console.error('Last.fm connection failed:', error); + alert('Failed to connect to Last.fm: ' + error.message); + lastfmConnectBtn.textContent = 'Connect Last.fm'; + lastfmConnectBtn.disabled = false; + if (authWindow && !authWindow.closed) authWindow.close(); + } + }); + + lastfmToggle?.addEventListener('change', (e) => { + lastFMStorage.setEnabled(e.target.checked); + }); + const themePicker = document.getElementById('theme-picker'); themePicker.querySelectorAll('.theme-option').forEach(option => { if (option.dataset.theme === currentTheme) { @@ -392,29 +506,31 @@ document.addEventListener('DOMContentLoaded', async () => { } }); }); + 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); - } -}); + 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() || { @@ -481,6 +597,19 @@ document.addEventListener('DOMContentLoaded', async () => { }); } + const normalizeToggle = document.querySelectorAll('.setting-item').forEach(item => { + const label = item.querySelector('.label'); + if (label && label.textContent.includes('Normalize Volume')) { + const toggle = item.querySelector('input[type="checkbox"]'); + if (toggle) { + toggle.checked = localStorage.getItem('normalize-volume') === 'true'; + toggle.addEventListener('change', (e) => { + localStorage.setItem('normalize-volume', e.target.checked ? 'true' : 'false'); + }); + } + } + }); + document.querySelector('.now-playing-bar .title').addEventListener('click', () => { const track = player.currentTrack; if (track?.album?.id) { @@ -597,10 +726,6 @@ document.addEventListener('DOMContentLoaded', async () => { }; const renderQueue = () => { - if (!queueModalOverlay.style.display || queueModalOverlay.style.display === "none") { - return; - } - const currentQueue = player.getCurrentQueue(); if (currentQueue.length === 0) { @@ -712,6 +837,22 @@ document.addEventListener('DOMContentLoaded', async () => { }); mainContent.addEventListener('click', e => { + const menuBtn = e.target.closest('.track-menu-btn'); + if (menuBtn) { + e.stopPropagation(); + const trackItem = menuBtn.closest('.track-item'); + if (trackItem && !trackItem.dataset.queueIndex) { + contextTrack = trackDataStore.get(trackItem); + if (contextTrack) { + const rect = menuBtn.getBoundingClientRect(); + contextMenu.style.top = `${rect.bottom + 5}px`; + contextMenu.style.left = `${rect.left}px`; + contextMenu.style.display = 'block'; + } + } + return; + } + const trackItem = e.target.closest('.track-item'); if (trackItem && !trackItem.dataset.queueIndex) { const parentList = trackItem.closest('.track-list'); @@ -809,6 +950,9 @@ document.addEventListener('DOMContentLoaded', async () => { }); audioPlayer.addEventListener('play', () => { + if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) { + scrobbler.updateNowPlaying(player.currentTrack); + } playPauseBtn.innerHTML = SVG_PAUSE; player.updateMediaSessionPlaybackState(); }); diff --git a/js/cache.js b/js/cache.js index a12d00a..61f3341 100644 --- a/js/cache.js +++ b/js/cache.js @@ -1,3 +1,4 @@ +//cache.js export class APICache { constructor(options = {}) { this.memoryCache = new Map(); diff --git a/js/lastfm.js b/js/lastfm.js new file mode 100644 index 0000000..eeb1241 --- /dev/null +++ b/js/lastfm.js @@ -0,0 +1,249 @@ +//lastfm.js +import { delay } from './utils.js'; + +export class LastFMScrobbler { + constructor() { + this.API_KEY = '0fc32c426d943d34a662977b31b98b67'; + this.API_SECRET = '53acf2466be726db021e7fdfd0ad1084'; + this.API_URL = 'https://ws.audioscrobbler.com/2.0/'; + + this.sessionKey = null; + this.username = null; + this.currentTrack = null; + this.scrobbleTimer = null; + this.scrobbleThreshold = 0; + this.hasScrobbled = false; + + this.loadSession(); + } + + loadSession() { + try { + const session = localStorage.getItem('lastfm-session'); + if (session) { + const data = JSON.parse(session); + this.sessionKey = data.key; + this.username = data.name; + } + } catch (e) { + console.error('Failed to load Last.fm session:', e); + } + } + + saveSession(sessionKey, username) { + this.sessionKey = sessionKey; + this.username = username; + localStorage.setItem('lastfm-session', JSON.stringify({ + key: sessionKey, + name: username + })); + } + + clearSession() { + this.sessionKey = null; + this.username = null; + localStorage.removeItem('lastfm-session'); + } + + isAuthenticated() { + return !!this.sessionKey; + } + + async generateSignature(params) { + const filteredParams = { ...params }; + delete filteredParams.format; + delete filteredParams.callback; + + const sortedKeys = Object.keys(filteredParams).sort(); + + const signatureString = sortedKeys + .map(key => `${key}${filteredParams[key]}`) + .join('') + this.API_SECRET; + + console.log('Signature string:', signatureString); + + try { + const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm'); + return md5(signatureString); + } catch (e) { + console.error('MD5 library not available'); + throw new Error('MD5 library required for Last.fm'); + } + } + + async makeRequest(method, params = {}, requiresAuth = false) { + const requestParams = { + method, + api_key: this.API_KEY, + ...params + }; + + if (requiresAuth && this.sessionKey) { + requestParams.sk = this.sessionKey; + } + + const signature = await this.generateSignature(requestParams); + + const formData = new URLSearchParams({ + ...requestParams, + api_sig: signature, + format: 'json' + }); + + try { + const response = await fetch(this.API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.message || 'Last.fm API error'); + } + + return data; + } catch (error) { + console.error('Last.fm API request failed:', error); + throw error; + } + } + + async getAuthUrl() { + try { + const data = await this.makeRequest('auth.getToken'); + const token = data.token; + + return { + token, + url: `https://www.last.fm/api/auth/?api_key=${this.API_KEY}&token=${token}` + }; + } catch (error) { + console.error('Failed to get auth URL:', error); + throw error; + } + } + + async completeAuthentication(token) { + try { + const data = await this.makeRequest('auth.getSession', { token }); + + if (data.session) { + this.saveSession(data.session.key, data.session.name); + return { + success: true, + username: data.session.name + }; + } + + throw new Error('No session returned'); + } catch (error) { + console.error('Authentication failed:', error); + throw error; + } + } + + async updateNowPlaying(track) { + if (!this.isAuthenticated()) return; + + this.currentTrack = track; + this.hasScrobbled = false; + this.clearScrobbleTimer(); + + try { + const params = { + artist: track.artist?.name || 'Unknown Artist', + track: track.title + }; + + if (track.album?.title) { + params.album = track.album.title; + } + + if (track.duration) { + params.duration = Math.floor(track.duration); + } + + if (track.trackNumber) { + params.trackNumber = track.trackNumber; + } + + await this.makeRequest('track.updateNowPlaying', params, true); + + console.log('Now playing updated:', track.title); + + this.scrobbleThreshold = Math.min(track.duration / 2, 240); + this.scheduleScrobble(this.scrobbleThreshold * 1000); + + } catch (error) { + console.error('Failed to update now playing:', error); + } + } + + scheduleScrobble(delay) { + this.clearScrobbleTimer(); + + this.scrobbleTimer = setTimeout(() => { + this.scrobbleCurrentTrack(); + }, delay); + } + + clearScrobbleTimer() { + if (this.scrobbleTimer) { + clearTimeout(this.scrobbleTimer); + this.scrobbleTimer = null; + } + } + + async scrobbleCurrentTrack() { + if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return; + + try { + const timestamp = Math.floor(Date.now() / 1000); + + const params = { + artist: this.currentTrack.artist?.name || 'Unknown Artist', + track: this.currentTrack.title, + timestamp: timestamp + }; + + if (this.currentTrack.album?.title) { + params.album = this.currentTrack.album.title; + } + + if (this.currentTrack.duration) { + params.duration = Math.floor(this.currentTrack.duration); + } + + if (this.currentTrack.trackNumber) { + params.trackNumber = this.currentTrack.trackNumber; + } + + await this.makeRequest('track.scrobble', params, true); + + this.hasScrobbled = true; + console.log('Scrobbled:', this.currentTrack.title); + + } catch (error) { + console.error('Failed to scrobble:', error); + } + } + + onTrackChange(track) { + if (!this.isAuthenticated()) return; + this.updateNowPlaying(track); + } + + onPlaybackStop() { + this.clearScrobbleTimer(); + } + + disconnect() { + this.clearSession(); + this.clearScrobbleTimer(); + this.currentTrack = null; + } +} \ No newline at end of file diff --git a/js/metadata.js b/js/metadata.js deleted file mode 100644 index 82e4f77..0000000 --- a/js/metadata.js +++ /dev/null @@ -1,210 +0,0 @@ -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 a39c5b5..d2210cb 100644 --- a/js/player.js +++ b/js/player.js @@ -1,3 +1,4 @@ +//player.js import { REPEAT_MODE, formatTime } from './utils.js'; export class Player { @@ -118,58 +119,74 @@ export class Player { } } - async playTrackFromQueue() { - const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; - if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { - return; - } - - const track = currentQueue[this.currentQueueIndex]; - this.currentTrack = track; - - document.querySelector('.now-playing-bar .cover').src = - this.api.getCoverUrl(track.album?.cover, '160'); - document.querySelector('.now-playing-bar .title').textContent = track.title; - document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist'; - document.title = `${track.title} • ${track.artist?.name || 'Unknown'}`; - - this.updatePlayingTrackIndicator(); - this.updateMediaSession(track); - - try { - let streamUrl; - - if (this.preloadCache.has(track.id)) { - streamUrl = this.preloadCache.get(track.id); - } else { - streamUrl = await this.api.getStreamUrl(track.id, this.quality); - } - - 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); - document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`; - document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track'; - } +async playTrackFromQueue() { + const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; + if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { + return; } + const track = currentQueue[this.currentQueueIndex]; + this.currentTrack = track; + + document.querySelector('.now-playing-bar .cover').src = + this.api.getCoverUrl(track.album?.cover, '1280'); + document.querySelector('.now-playing-bar .title').textContent = track.title; + document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist'; + document.title = `${track.title} • ${track.artist?.name || 'Unknown'}`; + + this.updatePlayingTrackIndicator(); + this.updateMediaSession(track); + + try { + let streamUrl; + + if (this.preloadCache.has(track.id)) { + streamUrl = this.preloadCache.get(track.id); + } else { + const trackData = await this.api.getTrack(track.id, this.quality); + + // Store replayGain for normalization + if (trackData.track?.replayGain !== undefined) { + window.currentGain = trackData.track.replayGain; + } else { + window.currentGain = track.replayGain || null; + } + + if (trackData.originalTrackUrl) { + streamUrl = trackData.originalTrackUrl; + } else { + streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest); + } + } + + 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; + } + + // Apply normalization if enabled + this.applyNormalization(); + + await this.audio.play(); + this.isCrossfading = false; + + this.updateMediaSessionPlaybackState(); + this.preloadNextTracks(); + this.setupCrossfadeListener(); + + } catch (error) { + console.error(`Could not play track: ${track.title}`, error); + document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`; + document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track'; + } +} + setupCrossfadeListener() { if (!this.crossfadeEnabled) return; @@ -391,7 +408,7 @@ export class Player { if (!('mediaSession' in navigator)) return; const artwork = []; - const sizes = ['96', '128', '192', '256', '384', '512']; + const sizes = ['1280']; const coverId = track.album?.cover; if (coverId) { @@ -414,6 +431,16 @@ export class Player { this.updateMediaSessionPlaybackState(); this.updateMediaSessionPositionState(); } +applyNormalization() { + const normalizeEnabled = localStorage.getItem('normalize-volume') === 'true'; + + if (normalizeEnabled && window.currentGain !== null && window.currentGain !== undefined) { + const baseVolume = parseFloat(localStorage.getItem('base-volume') || '0.7'); + const replayGain = parseFloat(window.currentGain); + const adjustment = Math.pow(10, replayGain / 20); + this.audio.volume = Math.min(1, Math.max(0, baseVolume * adjustment)); + } +} updateMediaSessionPlaybackState() { if (!('mediaSession' in navigator)) return; @@ -440,4 +467,4 @@ export class Player { console.debug('Failed to update Media Session position:', error); } } -} \ No newline at end of file +} diff --git a/js/storage.js b/js/storage.js index df2a66b..6a6fa80 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1,3 +1,4 @@ +//storage.js export const apiSettings = { STORAGE_KEY: 'monochrome-api-instances', INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json', @@ -53,8 +54,8 @@ export const apiSettings = { async speedTestInstance(url) { const testUrl = url.endsWith('/') - ? `${url}search/?s=kanye` - : `${url}/search/?s=kanye`; + ? `${url}track/?id=204567804&quality=HIGH` + : `${url}/track/?id=204567804&quality=HIGH`; const startTime = performance.now(); @@ -276,4 +277,20 @@ export const themeManager = { root.style.setProperty(`--${key}`, value); } } +}; + +export const lastFMStorage = { + STORAGE_KEY: 'lastfm-enabled', + + isEnabled() { + try { + return localStorage.getItem(this.STORAGE_KEY) === 'true'; + } catch (e) { + return false; + } + }, + + setEnabled(enabled) { + localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); + } }; \ No newline at end of file diff --git a/js/ui.js b/js/ui.js index e44f0cd..c68ba5c 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,3 +1,4 @@ +//ui.js import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent } from './utils.js'; import { recentActivityManager } from './storage.js'; @@ -10,28 +11,46 @@ export class UIRenderer { return 'E'; } + createTrackMenuButton() { + return ` + + `; +} createTrackItemHTML(track, index, showCover = false) { - const playIconSmall = ''; - const trackNumberHTML = `
${showCover ? playIconSmall : index + 1}
`; - const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : ''; - - return ` -
- ${trackNumberHTML} -
- ${showCover ? `Track Cover` : ''} -
-
- ${track.title} - ${explicitBadge} -
-
${track.artist?.name ?? 'Unknown Artist'}
+ const playIconSmall = ''; + const trackNumberHTML = `
${showCover ? playIconSmall : index + 1}
`; + const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : ''; + + return ` +
+ ${trackNumberHTML} +
+ ${showCover ? `Track Cover` : ''} +
+
+ ${track.title} + ${explicitBadge}
+
${track.artist?.name ?? 'Unknown Artist'}
-
${formatTime(track.duration)}
- `; - } +
${formatTime(track.duration)}
+ +
+ `; +} createAlbumCardHTML(album) { const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : ''; @@ -129,18 +148,71 @@ export class UIRenderer { } } - renderHomePage() { - this.showPage('home'); - const recents = recentActivityManager.getRecents(); - - document.getElementById('home-recent-albums').innerHTML = recents.albums.length +async renderHomePage() { + this.showPage('home'); + const recents = recentActivityManager.getRecents(); + + const albumsContainer = document.getElementById('home-recent-albums'); + const artistsContainer = document.getElementById('home-recent-artists'); + + if (recents.albums.length > 0 || recents.artists.length > 0) { + albumsContainer.innerHTML = recents.albums.length ? recents.albums.map(album => this.createAlbumCardHTML(album)).join('') : createPlaceholder("You haven't viewed any albums yet."); - document.getElementById('home-recent-artists').innerHTML = recents.artists.length + artistsContainer.innerHTML = recents.artists.length ? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('') : createPlaceholder("You haven't viewed any artists yet."); + } else { + // Load from API + albumsContainer.innerHTML = this.createSkeletonCards(6, false); + artistsContainer.innerHTML = this.createSkeletonCards(6, true); + + const homeData = await window.loadHomeFeed(this.api, this); + + if (homeData && homeData.rows) { + let albums = []; + let playlists = []; + + homeData.rows.forEach(row => { + row.modules?.forEach(module => { + if (module.type === 'ALBUM_LIST' && module.pagedList?.items) { + albums.push(...module.pagedList.items); + } else if (module.type === 'PLAYLIST_LIST' && module.pagedList?.items) { + playlists.push(...module.pagedList.items); + } + }); + }); + + if (albums.length > 0) { + albumsContainer.innerHTML = albums.slice(0, 10).map(album => + this.createAlbumCardHTML(album) + ).join(''); + } else { + albumsContainer.innerHTML = createPlaceholder("No albums available."); + } + + if (playlists.length > 0) { + document.querySelector('#home-recent-artists').parentElement.querySelector('.section-title').textContent = 'Featured Playlists'; + artistsContainer.innerHTML = playlists.slice(0, 10).map(playlist => ` + +
+ ${playlist.title} +
+

${playlist.title}

+

${playlist.numberOfTracks} tracks

+
+ `).join(''); + } else { + artistsContainer.innerHTML = createPlaceholder("No playlists available."); + } + } else { + albumsContainer.innerHTML = createPlaceholder("Unable to load content."); + artistsContainer.innerHTML = createPlaceholder("Unable to load content."); + } } +} async renderSearchPage(query) { this.showPage('search'); diff --git a/js/utils.js b/js/utils.js index efc61a6..05754a2 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,3 +1,4 @@ +//utils.js export const QUALITY = 'LOSSLESS'; export const REPEAT_MODE = { diff --git a/manifest.json b/manifest.json index a016477..0a211d4 100644 --- a/manifest.json +++ b/manifest.json @@ -27,7 +27,10 @@ "purpose": "maskable" } ], - "categories": ["music", "entertainment"], + "categories": [ + "music", + "entertainment" + ], "shortcuts": [ { "name": "Search", diff --git a/styles.css b/styles.css index 4c9fc13..34f2ebd 100644 --- a/styles.css +++ b/styles.css @@ -1,3 +1,18 @@ +:root { + --spacing-xs: 0.5rem; + --spacing-sm: 0.75rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + --radius: 0.5rem; + --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.15); + --shadow-md: 0 6px 16px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.8); +} + :root[data-theme="monochrome"] { --background: #000; --foreground: #fafafa; @@ -12,16 +27,9 @@ --border: #27272a; --input: #27272a; --ring: #fafafa; - --radius: .5rem; - --highlight: #ffffff; + --highlight: #fff; --active-highlight: var(--highlight); --explicit-badge: #fafafa; - --spacing-xs: 0.5rem; - --spacing-sm: 0.75rem; - --spacing-md: 1rem; - --spacing-lg: 1.5rem; - --spacing-xl: 2rem; - --spacing-2xl: 3rem; } :root[data-theme="dark"] { @@ -30,7 +38,7 @@ --card: #1a1a1a; --card-foreground: #ededed; --primary: #3b82f6; - --primary-foreground: #ffffff; + --primary-foreground: #fff; --secondary: #2a2a2a; --secondary-foreground: #ededed; --muted: #2a2a2a; @@ -38,16 +46,9 @@ --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"] { @@ -64,16 +65,9 @@ --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"] { @@ -82,7 +76,7 @@ --card: #1e0a2e; --card-foreground: #f3e8ff; --primary: #a855f7; - --primary-foreground: #ffffff; + --primary-foreground: #fff; --secondary: #2d1545; --secondary-foreground: #f3e8ff; --muted: #2d1545; @@ -90,16 +84,9 @@ --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"] { @@ -116,16 +103,9 @@ --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 { @@ -151,8 +131,6 @@ img { max-width: 100%; display: block; background-color: var(--muted); - color: transparent; - font-size: 0; border: none; } @@ -164,11 +142,9 @@ a { .app-container { display: grid; height: 100vh; - grid-template-columns: 280px 1fr; - grid-template-rows: 1fr auto; - grid-template-areas: - "sidebar main" - "player player"; + grid-template: + "sidebar main" 1fr + "player player" auto / 280px 1fr; } .sidebar { @@ -179,7 +155,7 @@ a { display: flex; flex-direction: column; gap: 2rem; - transition: transform .3s cubic-bezier(0.4, 0, 0.2, 1); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 2000; } @@ -204,7 +180,7 @@ a { .sidebar-logo { display: flex; align-items: center; - gap: .75rem; + gap: 0.75rem; font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; @@ -222,13 +198,12 @@ a { .sidebar-nav .nav-item a { display: flex; align-items: center; - gap: .75rem; - padding: .75rem; + gap: 0.75rem; + padding: 0.75rem; border-radius: var(--radius); color: var(--muted-foreground); - text-decoration: none; font-weight: 500; - transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); + transition: all var(--transition); cursor: pointer; } @@ -247,24 +222,13 @@ a { height: 20px; } -.page { +#sidebar-overlay { display: none; -} - -.page.active { - display: block; - animation: fadeIn .25s cubic-bezier(0.4, 0, 0.2, 1); -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1999; + backdrop-filter: blur(2px); } .main-header { @@ -283,7 +247,7 @@ a { cursor: pointer; padding: 0.5rem; border-radius: var(--radius); - transition: background-color .2s; + transition: background-color var(--transition); } .hamburger-menu:hover { @@ -298,7 +262,7 @@ a { .search-bar svg { position: absolute; - left: .75rem; + left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--muted-foreground); @@ -309,13 +273,13 @@ a { .search-bar input { width: 100%; - padding: .75rem .75rem .75rem 2.5rem; + padding: 0.75rem 0.75rem 0.75rem 2.5rem; background-color: var(--input); border: 1px solid var(--border); border-radius: var(--radius); color: var(--foreground); font-size: 1rem; - transition: border-color .2s; + transition: border-color var(--transition); } .search-bar input:focus { @@ -323,6 +287,77 @@ a { border-color: var(--ring); } +.page { + display: none; +} + +.page.active { + display: block; + animation: fadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + .content-section { margin-bottom: var(--spacing-2xl); } @@ -349,7 +384,7 @@ a { font-size: 1rem; font-weight: 500; border-bottom: 2px solid transparent; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: all var(--transition); } .search-tab:hover { @@ -381,7 +416,7 @@ a { background-color: var(--card); border-radius: var(--radius); padding: 1rem; - transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); + transition: all var(--transition); } .card:hover { @@ -415,14 +450,14 @@ a { .card-title { font-weight: 600; - margin-bottom: .25rem; + margin-bottom: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card-subtitle { - font-size: .9rem; + font-size: 0.9rem; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; @@ -434,7 +469,7 @@ a { align-items: center; justify-content: center; background-color: var(--explicit-badge); - color: rgb(0, 0, 0); + color: #000; font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.35rem; @@ -450,31 +485,31 @@ a { gap: 2px; } -.track-list .track-list-header { - display: grid; - grid-template-columns: 40px 1fr auto; - align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-sm) var(--spacing-sm); - color: var(--muted-foreground); - font-size: .9rem; - border-bottom: 1px solid var(--border); - margin-bottom: var(--spacing-xs); -} - -.track-list .track-list-header .duration-header { - justify-self: flex-end; -} - -.track-item { +.track-list-header { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: var(--spacing-md); padding: var(--spacing-sm); + color: var(--muted-foreground); + font-size: 0.9rem; + border-bottom: 1px solid var(--border); + margin-bottom: var(--spacing-xs); +} + +.track-list-header .duration-header { + justify-self: flex-end; +} + +.track-item { + display: grid; + grid-template-columns: 40px 1fr auto auto; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm); border-radius: var(--radius); cursor: pointer; - transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); + transition: all var(--transition); position: relative; } @@ -482,10 +517,15 @@ a { background-color: var(--secondary); } -.track-item .track-number { +.track-item.playing .track-number, +.track-item.playing .track-item-details .title { + color: var(--highlight); +} + +.track-number { color: var(--muted-foreground); text-align: center; - font-size: .9rem; + font-size: 0.9rem; display: flex; align-items: center; justify-content: center; @@ -521,7 +561,7 @@ a { } .track-item-details .artist { - font-size: .9rem; + font-size: 0.9rem; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; @@ -529,14 +569,36 @@ a { } .track-item-duration { - font-size: .9rem; + font-size: 0.9rem; color: var(--muted-foreground); justify-self: flex-end; } -.track-item.playing .track-number, -.track-item.playing .track-item-details .title { - color: var(--highlight); +.track-menu-btn { + background: transparent; + border: none; + color: var(--muted-foreground); + cursor: pointer; + padding: 0.5rem; + border-radius: var(--radius); + transition: all var(--transition); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + z-index: 10; +} + +.track-item:hover .track-menu-btn, +@media (hover: none) { + .track-menu-btn { + opacity: 1; + } +} + +.track-menu-btn:hover { + background-color: var(--secondary); + color: var(--foreground); } .detail-header { @@ -554,7 +616,7 @@ a { background-color: var(--muted); border-radius: var(--radius); object-fit: cover; - box-shadow: 0 10px 30px rgba(0, 0, 0, .5); + box-shadow: var(--shadow-lg); transition: opacity 0.3s ease-in-out; } @@ -568,7 +630,7 @@ a { .detail-header-info .type { font-weight: 600; - margin-bottom: .5rem; + margin-bottom: 0.5rem; } .detail-header-info .title { @@ -605,13 +667,13 @@ a { 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); + transition: all var(--transition); + box-shadow: var(--shadow-sm); } .btn-primary:hover { transform: scale(1.05); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + box-shadow: var(--shadow-md); } .btn-primary:disabled { @@ -624,6 +686,35 @@ a { flex-shrink: 0; } +.btn-secondary { + padding: 0.5rem 1rem; + background-color: var(--secondary); + color: var(--foreground); + border: none; + border-radius: var(--radius); + cursor: pointer; + font-weight: 500; + transition: all var(--transition); +} + +.btn-secondary:hover { + background-color: var(--muted); +} + +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary.danger { + background: #ef4444; + color: #fff; +} + +.btn-secondary.danger:hover { + background: #dc2626; +} + .settings-list { max-width: 800px; } @@ -647,11 +738,12 @@ a { } .setting-item .description { - font-size: .9rem; + font-size: 0.9rem; color: var(--muted-foreground); } -.setting-item select { +.setting-item select, +.setting-item input[type="number"] { background-color: var(--input); color: var(--foreground); border: 1px solid var(--border); @@ -660,11 +752,6 @@ a { } .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; } @@ -684,16 +771,13 @@ a { .slider { position: absolute; cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; background-color: var(--secondary); - transition: .3s cubic-bezier(0.4, 0, 0.2, 1); + transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 24px; } -.slider:before { +.slider::before { position: absolute; content: ""; height: 16px; @@ -701,7 +785,7 @@ a { left: 4px; bottom: 4px; background-color: var(--foreground); - transition: .3s cubic-bezier(0.4, 0, 0.2, 1); + transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 50%; } @@ -709,32 +793,12 @@ input:checked + .slider { background-color: var(--primary); } -input:checked + .slider:before { +input:checked + .slider::before { transform: translateX(16px); background-color: var(--primary-foreground); } -.btn-secondary { - padding: 0.5rem 1rem; - background-color: var(--secondary); - color: var(--foreground); - border: none; - border-radius: var(--radius); - cursor: pointer; - font-weight: 500; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -.btn-secondary:hover { - background-color: var(--muted); -} - -.btn-secondary:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.now-playing-bar .track-info { +.track-info { display: flex; align-items: center; gap: 1rem; @@ -760,7 +824,7 @@ input:checked + .slider:before { overflow: hidden; text-overflow: ellipsis; cursor: pointer; - transition: color 0.2s; + transition: color var(--transition); } .track-info .details .title:hover { @@ -768,13 +832,13 @@ input:checked + .slider:before { } .track-info .details .artist { - font-size: .8rem; + font-size: 0.8rem; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; - transition: color 0.2s; + transition: color var(--transition); } .track-info .details .artist:hover { @@ -799,7 +863,7 @@ input:checked + .slider:before { border: none; color: var(--muted-foreground); cursor: pointer; - transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); + transition: all var(--transition); display: flex; align-items: center; justify-content: center; @@ -832,10 +896,6 @@ input:checked + .slider:before { color: var(--primary-foreground); width: 36px; height: 36px; - border-radius: 50%; - display: flex; - justify-content: center; - align-items: center; } .player-controls .buttons .play-pause-btn:hover { @@ -849,8 +909,8 @@ input:checked + .slider:before { max-width: 500px; display: flex; align-items: center; - gap: .75rem; - font-size: .8rem; + gap: 0.75rem; + font-size: 0.8rem; color: var(--muted-foreground); } @@ -868,7 +928,7 @@ input:checked + .slider:before { height: 100%; background-color: var(--foreground); border-radius: 2px; - transition: width .1s linear; + transition: width 0.1s linear; position: relative; } @@ -892,7 +952,7 @@ input:checked + .slider:before { display: flex; justify-content: flex-end; align-items: center; - gap: .75rem; + gap: 0.75rem; } .volume-controls button { @@ -900,7 +960,7 @@ input:checked + .slider:before { border: none; color: var(--muted-foreground); cursor: pointer; - transition: color .2s; + transition: color var(--transition); padding: 0.5rem; border-radius: var(--radius); } @@ -942,42 +1002,49 @@ input:checked + .slider:before { border-radius: 50%; } -#context-menu { +#context-menu, +.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); + padding: 0.5rem; + box-shadow: var(--shadow-lg); z-index: 1000; min-width: 160px; } -#context-menu ul { +.queue-track-menu.show { + display: block; + z-index: 1001; + min-width: 120px; +} + +#context-menu ul, +.queue-track-menu ul { list-style: none; } -#context-menu li { - padding: .5rem .75rem; +#context-menu li, +.queue-track-menu li { + padding: 0.5rem 0.75rem; cursor: pointer; border-radius: 4px; - transition: background-color .2s cubic-bezier(0.4, 0, 0.2, 1); + transition: background-color var(--transition); font-size: 0.9rem; } -#context-menu li:hover { +#context-menu li:hover, +.queue-track-menu li:hover { background-color: var(--secondary); } #queue-modal-overlay { display: none; position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, .7); + inset: 0; + background-color: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); z-index: 1000; justify-content: center; @@ -993,21 +1060,10 @@ input:checked + .slider:before { border-radius: var(--radius); display: flex; flex-direction: column; - box-shadow: 0 20px 60px rgba(0, 0, 0, .8); + box-shadow: var(--shadow-xl); animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); } -@keyframes scaleIn { - from { - transform: scale(0.95); - opacity: 0; - } - to { - transform: scale(1); - opacity: 1; - } -} - #queue-modal-header { padding: 1rem; display: flex; @@ -1032,7 +1088,7 @@ input:checked + .slider:before { align-items: center; justify-content: center; border-radius: var(--radius); - transition: all .2s; + transition: all var(--transition); } #queue-modal-header button:hover { @@ -1042,7 +1098,7 @@ input:checked + .slider:before { #queue-list { overflow-y: auto; - padding: .5rem; + padding: 0.5rem; } .queue-track-item { @@ -1053,7 +1109,7 @@ input:checked + .slider:before { padding: var(--spacing-sm); border-radius: var(--radius); cursor: grab; - transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); + transition: all var(--transition); margin-bottom: 2px; } @@ -1084,7 +1140,7 @@ input:checked + .slider:before { cursor: pointer; padding: 4px; border-radius: 4px; - transition: all .2s; + transition: all var(--transition); display: flex; align-items: center; justify-content: center; @@ -1095,234 +1151,16 @@ input:checked + .slider:before { 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); text-align: center; } -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: .5; } -} - .placeholder-text.loading { animation: pulse 1.5s infinite ease-in-out; } -#api-instance-manager { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--border); -} - -#api-instance-list { - list-style: none; - margin-bottom: 1rem; -} - -#api-instance-list li { - display: flex; - align-items: center; - gap: .75rem; - padding: .75rem; - background-color: var(--secondary); - border-radius: var(--radius); - margin-bottom: .5rem; -} - -#api-instance-list li .instance-url { - flex-grow: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: .9rem; -} - -#api-instance-list li .controls { - display: flex; - gap: .5rem; -} - -#api-instance-list li button { - background: transparent; - border: none; - color: var(--muted-foreground); - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: all .2s cubic-bezier(0.4, 0, 0.2, 1); -} - -#api-instance-list li button:hover { - color: var(--foreground); - background-color: var(--muted); -} - -#api-instance-list li button:disabled { - opacity: .3; - cursor: not-allowed; -} - -#sidebar-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, .5); - z-index: 1999; - backdrop-filter: blur(2px); -} - -.about-content { - padding: 1rem 0; -} - -.about-description { - color: var(--foreground); - line-height: 1.6; - margin-bottom: 1.5rem; -} - -.about-features, -.about-tech { - margin-bottom: 1.5rem; -} - -.about-features h4, -.about-tech h4 { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--foreground); -} - -.about-features ul { - list-style: none; - padding: 0; -} - -.about-features li { - padding: 0.5rem 0; - padding-left: 1.5rem; - position: relative; - color: var(--foreground); - line-height: 1.5; -} - -.about-features li::before { - content: "✓"; - position: absolute; - left: 0; - color: var(--highlight); - font-weight: bold; -} - -.about-tech p { - color: var(--muted-foreground); - font-family: 'Courier New', monospace; - font-size: 0.9rem; -} - -.about-links { - display: flex; - gap: 1rem; - margin: 1.5rem 0; - flex-wrap: wrap; -} - -.github-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.25rem; - background-color: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--foreground); - text-decoration: none; - font-weight: 500; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -.github-link:hover { - background-color: var(--secondary); - border-color: var(--highlight); - transform: translateY(-2px); -} - -.github-link svg { - flex-shrink: 0; -} - -.about-footer { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border); -} - -.about-footer p { - margin: 0.5rem 0; - font-size: 0.9rem; -} - -.about-footer .version { - color: var(--foreground); - font-weight: 600; -} - -.about-footer .license { - color: var(--muted-foreground); -} - -.about-footer .disclaimer { - color: var(--muted-foreground); - font-size: 0.8rem; - font-style: italic; - margin-top: 1rem; - padding: 0.75rem; - background-color: var(--secondary); - border-radius: var(--radius); - border-left: 3px solid var(--muted-foreground); -} - .skeleton { background: linear-gradient( 90deg, @@ -1335,15 +1173,6 @@ input:checked + .slider:before { border-radius: var(--radius); } -@keyframes skeleton-loading { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -} - .skeleton-track { display: grid; grid-template-columns: 40px 1fr auto; @@ -1430,6 +1259,63 @@ input:checked + .slider:before { width: 100%; } +#api-instance-manager { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +#api-instance-list { + list-style: none; + margin-bottom: 1rem; +} + +#api-instance-list li { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background-color: var(--secondary); + border-radius: var(--radius); + margin-bottom: 0.5rem; +} + +#api-instance-list li .instance-url { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9rem; +} + +#api-instance-list li .controls { + display: flex; + gap: 0.5rem; +} + +#api-instance-list li button { + background: transparent; + border: none; + color: var(--muted-foreground); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all var(--transition); +} + +#api-instance-list li button:hover { + color: var(--foreground); + background-color: var(--muted); +} + +#api-instance-list li button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + .theme-picker { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); @@ -1442,7 +1328,7 @@ input:checked + .slider:before { border: 2px solid var(--border); border-radius: var(--radius); cursor: pointer; - transition: all 0.2s; + transition: all var(--transition); text-align: center; font-weight: 500; } @@ -1502,35 +1388,114 @@ input:checked + .slider:before { margin-top: 1rem; } -@keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } +.about-content { + padding: 1rem 0; } -@keyframes slideOut { - from { - transform: translateX(0); - opacity: 1; - } - to { - transform: translateX(100%); - opacity: 0; - } +.about-description { + color: var(--foreground); + line-height: 1.6; + margin-bottom: 1.5rem; } -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } +.about-features, +.about-tech { + margin-bottom: 1.5rem; } -.animate-spin { - animation: spin 1s linear infinite; +.about-features h4, +.about-tech h4 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--foreground); +} + +.about-features ul { + list-style: none; + padding: 0; +} + +.about-features li { + padding: 0.5rem 0 0.5rem 1.5rem; + position: relative; + color: var(--foreground); + line-height: 1.5; +} + +.about-features li::before { + content: "✓"; + position: absolute; + left: 0; + color: var(--highlight); + font-weight: bold; +} + +.about-tech p { + color: var(--muted-foreground); + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +.about-links { + display: flex; + gap: 1rem; + margin: 1.5rem 0; + flex-wrap: wrap; +} + +.github-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background-color: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-weight: 500; + transition: all var(--transition); +} + +.github-link:hover { + background-color: var(--secondary); + border-color: var(--highlight); + transform: translateY(-2px); +} + +.github-link svg { + flex-shrink: 0; +} + +.about-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +.about-footer p { + margin: 0.5rem 0; + font-size: 0.9rem; +} + +.about-footer .version { + color: var(--foreground); + font-weight: 600; +} + +.about-footer .license { + color: var(--muted-foreground); +} + +.about-footer .disclaimer { + color: var(--muted-foreground); + font-size: 0.8rem; + font-style: italic; + margin-top: 1rem; + padding: 0.75rem; + background-color: var(--secondary); + border-radius: var(--radius); + border-left: 3px solid var(--muted-foreground); } #download-notifications { @@ -1549,7 +1514,7 @@ input:checked + .slider:before { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + box-shadow: var(--shadow-lg); animation: slideIn 0.3s ease; } @@ -1558,6 +1523,12 @@ input:checked + .slider:before { color: var(--foreground) !important; } +#lastfm-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + @media (max-width: 1024px) { .app-container { grid-template-columns: 240px 1fr; @@ -1579,12 +1550,10 @@ input:checked + .slider:before { @media (max-width: 768px) { .app-container { - grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto; - grid-template-areas: - "header" - "main" - "player"; + grid-template: + "header" auto + "main" 1fr + "player" auto / 1fr; } .main-content { @@ -1594,7 +1563,7 @@ input:checked + .slider:before { .main-header { grid-area: header; - padding: var(--spacing-md) var(--spacing-md) 0 var(--spacing-md); + padding: var(--spacing-md) var(--spacing-md) 0; margin-bottom: var(--spacing-md); } @@ -1604,7 +1573,7 @@ input:checked + .slider:before { left: 0; height: 100%; transform: translateX(-100%); - box-shadow: 0 0 20px rgba(0, 0, 0, .5); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); } .sidebar.is-open { @@ -1655,19 +1624,23 @@ input:checked + .slider:before { line-height: 1.2; } + .detail-header-actions, + .btn-primary { + width: 100%; + } + .now-playing-bar { - grid-template-columns: 1fr; - grid-template-rows: auto auto; + grid-template: + "track" auto + "controls" auto / 1fr; gap: var(--spacing-md); padding: var(--spacing-md); height: auto; } .now-playing-bar .track-info { - grid-column: 1; - grid-row: 1; + grid-area: track; width: 100%; - justify-content: flex-start; } .track-info .cover { @@ -1680,11 +1653,8 @@ input:checked + .slider:before { } .now-playing-bar .player-controls { - grid-column: 1; - grid-row: 2; + grid-area: controls; width: 100%; - flex-direction: column; - gap: var(--spacing-sm); } .player-controls .progress-container { @@ -1714,20 +1684,16 @@ input:checked + .slider:before { width: 100%; } - .detail-header-actions { - width: 100%; - } - - .btn-primary { - width: 100%; - } - #download-notifications { bottom: 160px; right: 10px; left: 10px; max-width: none; } + + .track-menu-btn { + opacity: 1; + } } @media (max-width: 480px) { @@ -1748,4 +1714,4 @@ input:checked + .slider:before { padding: var(--spacing-sm) var(--spacing-md); font-size: 0.9rem; } -} +} \ No newline at end of file