diff --git a/index.html b/index.html index d50b988..d000bea 100644 --- a/index.html +++ b/index.html @@ -222,7 +222,26 @@ - +
+
+ Last.fm Scrobbling + Connect your Last.fm account to scrobble tracks +
+
+ +
+
+ +
Audio Quality diff --git a/js/app.js b/js/app.js index 11f4acf..021a203 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, @@ -337,6 +338,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 +374,90 @@ 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'); + + 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(); + } + } else { + try { + lastfmConnectBtn.disabled = true; + lastfmConnectBtn.textContent = 'Opening Last.fm...'; + + const { token, url } = await scrobbler.getAuthUrl(); + + const authWindow = window.open(url, 'lastfm-auth', 'width=800,height=600'); + + 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; + 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; + } + } + }); + + 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 +479,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() || { @@ -809,6 +898,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/lastfm.js b/js/lastfm.js new file mode 100644 index 0000000..5e87ae7 --- /dev/null +++ b/js/lastfm.js @@ -0,0 +1,248 @@ +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/storage.js b/js/storage.js index 7ced455..e148988 100644 --- a/js/storage.js +++ b/js/storage.js @@ -276,4 +276,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/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 f19e7ef..c4ac053 100644 --- a/styles.css +++ b/styles.css @@ -1748,4 +1748,18 @@ input:checked + .slider:before { padding: var(--spacing-sm) var(--spacing-md); font-size: 0.9rem; } +} +.btn-secondary.danger { + background: #ef4444; + color: white; +} + +.btn-secondary.danger:hover { + background: #dc2626; +} + +#lastfm-controls { + display: flex; + align-items: center; + gap: 0.5rem; } \ No newline at end of file