diff --git a/index.html b/index.html index 6d3d827..7c5b05c 100644 --- a/index.html +++ b/index.html @@ -2315,6 +2315,19 @@ style="width: 250px" /> +
@@ -2354,6 +2367,83 @@
+ +
+
+
+ Maloja Scrobbling + Submit listens to a self-hosted Maloja server +
+ +
+ + +
+ +
+
+
+ Libre.fm Scrobbling + Connect your Libre.fm account to scrobble tracks +
+
+ +
+
+ + + + +
diff --git a/js/events.js b/js/events.js index 823a30d..6f29b67 100644 --- a/js/events.js +++ b/js/events.js @@ -10,7 +10,7 @@ import { SVG_BIN, getTrackArtists, } from './utils.js'; -import { lastFMStorage, waveformSettings } from './storage.js'; +import { lastFMStorage, libreFmSettings, waveformSettings } from './storage.js'; import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js'; import { downloadQualitySettings } from './storage.js'; import { updateTabTitle, navigate } from './router.js'; @@ -858,8 +858,13 @@ export async function handleTrackAction( const added = await db.toggleFavorite(type, item); syncManager.syncLibraryItem(type, item, added); - if (added && type === 'track' && scrobbler && lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) { - scrobbler.loveTrack(item); + if (added && type === 'track' && scrobbler) { + if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) { + scrobbler.loveTrack(item); + } + if (libreFmSettings.isEnabled() && libreFmSettings.shouldLoveOnLike()) { + scrobbler.loveTrack(item); + } } // Update all instances of this item's like button on the page diff --git a/js/librefm.js b/js/librefm.js new file mode 100644 index 0000000..8f715e4 --- /dev/null +++ b/js/librefm.js @@ -0,0 +1,289 @@ +import { libreFmSettings } from './storage.js'; + +export class LibreFmScrobbler { + constructor() { + this.API_KEY = 'monochrome_music_app'; + this.API_SECRET = 'monochrome_music_secret_2024'; + this.API_URL = 'https://libre.fm/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('librefm-session'); + if (session) { + const data = JSON.parse(session); + this.sessionKey = data.key; + this.username = data.name; + } + } catch { + console.error('Failed to load Libre.fm session'); + } + } + + saveSession(sessionKey, username) { + this.sessionKey = sessionKey; + this.username = username; + localStorage.setItem( + 'librefm-session', + JSON.stringify({ + key: sessionKey, + name: username, + }) + ); + } + + clearSession() { + this.sessionKey = null; + this.username = null; + localStorage.removeItem('librefm-session'); + } + + isAuthenticated() { + return !!this.sessionKey && libreFmSettings.isEnabled(); + } + + _getScrobbleArtist(track) { + if (!track) return 'Unknown Artist'; + + let artistName = 'Unknown Artist'; + + if (track.artist?.name) { + artistName = track.artist.name; + } else if (typeof track.artist === 'string') { + artistName = track.artist; + } else if (track.artists && track.artists.length > 0) { + const first = track.artists[0]; + artistName = typeof first === 'string' ? first : first.name || 'Unknown Artist'; + } + + if (typeof artistName !== 'string') return 'Unknown Artist'; + + artistName = artistName + .split(/\s*[&]\s*|\s+feat\.?\s+|\s+ft\.?\s+|\s+featuring\s+|\s+with\s+|\s+x\s+/i)[0] + .trim(); + + return artistName || 'Unknown Artist'; + } + + 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; + + try { + const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm'); + return md5(signatureString); + } catch { + console.error('MD5 library not available'); + throw new Error('MD5 library required for Libre.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 || 'Libre.fm API error'); + } + + return data; + } catch (error) { + console.error('Libre.fm API request failed:', error); + throw error; + } + } + + async getAuthUrl() { + try { + // First, get a token from Libre.fm + const data = await this.makeRequest('auth.getToken'); + const token = data.token; + + localStorage.setItem('librefm-pending-token', token); + + return { + token, + url: `https://libre.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); + localStorage.removeItem('librefm-pending-token'); + 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 scrobbleTitle = track.cleanTitle || track.title; + const params = { + artist: this._getScrobbleArtist(track), + track: scrobbleTitle, + }; + + 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('[Libre.fm] Now playing updated:', scrobbleTitle); + + this.scrobbleThreshold = Math.min(track.duration / 2, 240); + this.scheduleScrobble(this.scrobbleThreshold * 1000); + } catch (error) { + console.error('[Libre.fm] 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 scrobbleTitle = this.currentTrack.cleanTitle || this.currentTrack.title; + + const params = { + artist: this._getScrobbleArtist(this.currentTrack), + track: scrobbleTitle, + 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('[Libre.fm] Scrobbled:', this.currentTrack.cleanTitle || this.currentTrack.title); + } catch (error) { + console.error('[Libre.fm] Failed to scrobble:', error); + } + } + + async loveTrack(track) { + if (!this.isAuthenticated()) return; + + try { + const params = { + artist: this._getScrobbleArtist(track), + track: track.title, + }; + + await this.makeRequest('track.love', params, true); + console.log('[Libre.fm] Loved track:', track.title); + } catch (error) { + console.error('[Libre.fm] Failed to love track:', error); + } + } + + onTrackChange(track) { + if (!this.isAuthenticated()) return; + this.updateNowPlaying(track); + } + + onPlaybackStop() { + this.clearScrobbleTimer(); + } + + disconnect() { + this.clearSession(); + this.clearScrobbleTimer(); + this.currentTrack = null; + } +} diff --git a/js/listenbrainz.js b/js/listenbrainz.js index 53594a4..cdb24af 100644 --- a/js/listenbrainz.js +++ b/js/listenbrainz.js @@ -2,13 +2,18 @@ import { listenBrainzSettings } from './storage.js'; export class ListenBrainzScrobbler { constructor() { - this.API_URL = 'https://api.listenbrainz.org/1'; + this.DEFAULT_API_URL = 'https://api.listenbrainz.org/1'; this.currentTrack = null; this.scrobbleTimer = null; this.scrobbleThreshold = 0; this.hasScrobbled = false; } + getApiUrl() { + const customUrl = listenBrainzSettings.getCustomUrl(); + return customUrl || this.DEFAULT_API_URL; + } + isEnabled() { return listenBrainzSettings.isEnabled() && !!listenBrainzSettings.getToken(); } @@ -89,7 +94,8 @@ export class ListenBrainzScrobbler { }; try { - const response = await fetch(`${this.API_URL}/submit-listens`, { + const apiUrl = this.getApiUrl(); + const response = await fetch(`${apiUrl}/submit-listens`, { method: 'POST', headers: { Authorization: `Token ${this.getToken()}`, diff --git a/js/maloja.js b/js/maloja.js new file mode 100644 index 0000000..cd9746f --- /dev/null +++ b/js/maloja.js @@ -0,0 +1,163 @@ +import { malojaSettings } from './storage.js'; + +export class MalojaScrobbler { + constructor() { + this.currentTrack = null; + this.scrobbleTimer = null; + this.scrobbleThreshold = 0; + this.hasScrobbled = false; + } + + getApiUrl() { + const customUrl = malojaSettings.getCustomUrl(); + // Remove trailing slash if present + return customUrl ? customUrl.replace(/\/$/, '') : ''; + } + + isEnabled() { + return malojaSettings.isEnabled() && !!malojaSettings.getToken() && !!this.getApiUrl(); + } + + getApiKey() { + return malojaSettings.getToken(); + } + + _getScrobbleArtist(track) { + if (!track) return 'Unknown Artist'; + + let artistName = 'Unknown Artist'; + + if (track.artist?.name) { + artistName = track.artist.name; + } else if (typeof track.artist === 'string') { + artistName = track.artist; + } else if (track.artists && track.artists.length > 0) { + const first = track.artists[0]; + artistName = typeof first === 'string' ? first : first.name || 'Unknown Artist'; + } + + if (typeof artistName !== 'string') return 'Unknown Artist'; + + artistName = artistName + .split(/\s*[&]\s*|\s+feat\.?\s+|\s+ft\.?\s+|\s+featuring\s+|\s+with\s+|\s+x\s+/i)[0] + .trim(); + + return artistName || 'Unknown Artist'; + } + + async submitScrobble(track, timestamp = null) { + if (!this.isEnabled()) return; + + const apiUrl = this.getApiUrl(); + const apiKey = this.getApiKey(); + + if (!apiUrl || !apiKey) return; + + const artist = this._getScrobbleArtist(track); + const title = track.cleanTitle || track.title; + + // Build the scrobble data + const scrobbleData = { + artist: artist, + title: title, + key: apiKey, + }; + + if (track.album?.title) { + scrobbleData.album = track.album.title; + } + + if (track.duration) { + scrobbleData.duration = Math.floor(track.duration); + } + + if (track.trackNumber) { + scrobbleData.track_number = track.trackNumber; + } + + if (timestamp) { + scrobbleData.time = timestamp; + } + + try { + // Try the newer Maloja API format first (mlj_1) + let response = await fetch(`${apiUrl}/apis/mlj_1/newscrobble`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(scrobbleData), + }); + + if (!response.ok) { + // Fallback to older API format + response = await fetch(`${apiUrl}/apis/native/newscrobble`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(scrobbleData), + }); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Maloja API Error ${response.status}: ${text}`); + } + + console.log(`[Maloja] Submitted scrobble: ${title} by ${artist}`); + } catch (error) { + console.error('[Maloja] Submission failed:', error); + } + } + + async updateNowPlaying(track) { + if (!this.isEnabled()) return; + + this.currentTrack = track; + this.hasScrobbled = false; + this.clearScrobbleTimer(); + + // Maloja doesn't have a separate "now playing" endpoint like Last.fm + // It just scrobbles when the track is actually played + // We'll set up the timer to scrobble after the threshold + + this.scrobbleThreshold = Math.min(track.duration / 2, 240); + this.scheduleScrobble(this.scrobbleThreshold * 1000); + } + + scheduleScrobble(delay) { + this.clearScrobbleTimer(); + this.scrobbleTimer = setTimeout(() => { + this.scrobbleCurrentTrack(); + }, delay); + } + + clearScrobbleTimer() { + if (this.scrobbleTimer) { + clearTimeout(this.scrobbleTimer); + this.scrobbleTimer = null; + } + } + + async scrobbleCurrentTrack() { + if (!this.isEnabled() || !this.currentTrack || this.hasScrobbled) return; + + const timestamp = Math.floor(Date.now() / 1000); + await this.submitScrobble(this.currentTrack, timestamp); + this.hasScrobbled = true; + } + + onTrackChange(track) { + this.updateNowPlaying(track); + } + + onPlaybackStop() { + this.clearScrobbleTimer(); + } + + disconnect() { + this.clearScrobbleTimer(); + this.currentTrack = null; + } +} diff --git a/js/multi-scrobbler.js b/js/multi-scrobbler.js index e4acdff..0e057ab 100644 --- a/js/multi-scrobbler.js +++ b/js/multi-scrobbler.js @@ -1,10 +1,14 @@ import { LastFMScrobbler } from './lastfm.js'; import { ListenBrainzScrobbler } from './listenbrainz.js'; +import { MalojaScrobbler } from './maloja.js'; +import { LibreFmScrobbler } from './librefm.js'; export class MultiScrobbler { constructor() { this.lastfm = new LastFMScrobbler(); this.listenbrainz = new ListenBrainzScrobbler(); + this.maloja = new MalojaScrobbler(); + this.librefm = new LibreFmScrobbler(); } // Proxy method for Last.fm specific usage (auth flow) @@ -12,30 +16,47 @@ export class MultiScrobbler { return this.lastfm; } + // Proxy method for Libre.fm specific usage (auth flow) + getLibreFm() { + return this.librefm; + } + isAuthenticated() { // Return true if any service is configured, so events.js will proceed to call updateNowPlaying // Individual services check their own enabled/auth state internally - return this.lastfm.isAuthenticated() || this.listenbrainz.isEnabled(); + return ( + this.lastfm.isAuthenticated() || + this.listenbrainz.isEnabled() || + this.maloja.isEnabled() || + this.librefm.isAuthenticated() + ); } updateNowPlaying(track) { this.lastfm.updateNowPlaying(track); this.listenbrainz.updateNowPlaying(track); + this.maloja.updateNowPlaying(track); + this.librefm.updateNowPlaying(track); } onTrackChange(track) { this.lastfm.onTrackChange(track); this.listenbrainz.onTrackChange(track); + this.maloja.onTrackChange(track); + this.librefm.onTrackChange(track); } onPlaybackStop() { this.lastfm.onPlaybackStop(); this.listenbrainz.onPlaybackStop(); + this.maloja.onPlaybackStop(); + this.librefm.onPlaybackStop(); } - // Love/Like is currently Last.fm specific in the UI, but we can extend later + // Love/Like tracks on all services that support it async loveTrack(track) { await this.lastfm.loveTrack(track); - // ListenBrainz feedback could be added here + await this.librefm.loveTrack(track); + // ListenBrainz and Maloja feedback could be added here when supported } } diff --git a/js/settings.js b/js/settings.js index e7bf331..dd006aa 100644 --- a/js/settings.js +++ b/js/settings.js @@ -18,6 +18,8 @@ import { playlistSettings, equalizerSettings, listenBrainzSettings, + malojaSettings, + libreFmSettings, homePageSettings, sidebarSectionSettings, } from './storage.js'; @@ -223,13 +225,17 @@ export function initializeSettings(scrobbler, player, api, ui) { // ======================================== const lbToggle = document.getElementById('listenbrainz-enabled-toggle'); const lbTokenSetting = document.getElementById('listenbrainz-token-setting'); + const lbCustomUrlSetting = document.getElementById('listenbrainz-custom-url-setting'); const lbTokenInput = document.getElementById('listenbrainz-token-input'); + const lbCustomUrlInput = document.getElementById('listenbrainz-custom-url-input'); const updateListenBrainzUI = () => { const isEnabled = listenBrainzSettings.isEnabled(); if (lbToggle) lbToggle.checked = isEnabled; if (lbTokenSetting) lbTokenSetting.style.display = isEnabled ? 'flex' : 'none'; + if (lbCustomUrlSetting) lbCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none'; if (lbTokenInput) lbTokenInput.value = listenBrainzSettings.getToken(); + if (lbCustomUrlInput) lbCustomUrlInput.value = listenBrainzSettings.getCustomUrl(); }; updateListenBrainzUI(); @@ -248,6 +254,164 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + if (lbCustomUrlInput) { + lbCustomUrlInput.addEventListener('change', (e) => { + listenBrainzSettings.setCustomUrl(e.target.value.trim()); + }); + } + + // ======================================== + // Maloja Settings + // ======================================== + const malojaToggle = document.getElementById('maloja-enabled-toggle'); + const malojaTokenSetting = document.getElementById('maloja-token-setting'); + const malojaCustomUrlSetting = document.getElementById('maloja-custom-url-setting'); + const malojaTokenInput = document.getElementById('maloja-token-input'); + const malojaCustomUrlInput = document.getElementById('maloja-custom-url-input'); + + const updateMalojaUI = () => { + const isEnabled = malojaSettings.isEnabled(); + if (malojaToggle) malojaToggle.checked = isEnabled; + if (malojaTokenSetting) malojaTokenSetting.style.display = isEnabled ? 'flex' : 'none'; + if (malojaCustomUrlSetting) malojaCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none'; + if (malojaTokenInput) malojaTokenInput.value = malojaSettings.getToken(); + if (malojaCustomUrlInput) malojaCustomUrlInput.value = malojaSettings.getCustomUrl(); + }; + + updateMalojaUI(); + + if (malojaToggle) { + malojaToggle.addEventListener('change', (e) => { + const enabled = e.target.checked; + malojaSettings.setEnabled(enabled); + updateMalojaUI(); + }); + } + + if (malojaTokenInput) { + malojaTokenInput.addEventListener('change', (e) => { + malojaSettings.setToken(e.target.value.trim()); + }); + } + + if (malojaCustomUrlInput) { + malojaCustomUrlInput.addEventListener('change', (e) => { + malojaSettings.setCustomUrl(e.target.value.trim()); + }); + } + + // ======================================== + // Libre.fm Settings + // ======================================== + const librefmConnectBtn = document.getElementById('librefm-connect-btn'); + const librefmStatus = document.getElementById('librefm-status'); + const librefmToggle = document.getElementById('librefm-toggle'); + const librefmToggleSetting = document.getElementById('librefm-toggle-setting'); + const librefmLoveToggle = document.getElementById('librefm-love-toggle'); + const librefmLoveSetting = document.getElementById('librefm-love-setting'); + + function updateLibreFmUI() { + if (scrobbler.librefm.isAuthenticated()) { + librefmStatus.textContent = `Connected as ${scrobbler.librefm.username}`; + librefmConnectBtn.textContent = 'Disconnect'; + librefmConnectBtn.classList.add('danger'); + librefmToggleSetting.style.display = 'flex'; + librefmLoveSetting.style.display = 'flex'; + librefmToggle.checked = libreFmSettings.isEnabled(); + librefmLoveToggle.checked = libreFmSettings.shouldLoveOnLike(); + } else { + librefmStatus.textContent = 'Connect your Libre.fm account to scrobble tracks'; + librefmConnectBtn.textContent = 'Connect Libre.fm'; + librefmConnectBtn.classList.remove('danger'); + librefmToggleSetting.style.display = 'none'; + librefmLoveSetting.style.display = 'none'; + } + } + + if (librefmConnectBtn) { + updateLibreFmUI(); + + librefmConnectBtn.addEventListener('click', async () => { + if (scrobbler.librefm.isAuthenticated()) { + if (confirm('Disconnect from Libre.fm?')) { + scrobbler.librefm.disconnect(); + updateLibreFmUI(); + } + return; + } + + const authWindow = window.open('', '_blank'); + librefmConnectBtn.disabled = true; + librefmConnectBtn.textContent = 'Opening Libre.fm...'; + + try { + const { token, url } = await scrobbler.librefm.getAuthUrl(); + + if (authWindow) { + authWindow.location.href = url; + } else { + alert('Popup blocked! Please allow popups.'); + librefmConnectBtn.textContent = 'Connect Libre.fm'; + librefmConnectBtn.disabled = false; + return; + } + + librefmConnectBtn.textContent = 'Waiting for authorization...'; + + let attempts = 0; + const maxAttempts = 30; + + const checkAuth = setInterval(async () => { + attempts++; + + if (attempts > maxAttempts) { + clearInterval(checkAuth); + librefmConnectBtn.textContent = 'Connect Libre.fm'; + librefmConnectBtn.disabled = false; + if (authWindow && !authWindow.closed) authWindow.close(); + alert('Authorization timed out. Please try again.'); + return; + } + + try { + const result = await scrobbler.librefm.completeAuthentication(token); + + if (result.success) { + clearInterval(checkAuth); + if (authWindow && !authWindow.closed) authWindow.close(); + updateLibreFmUI(); + librefmConnectBtn.disabled = false; + libreFmSettings.setEnabled(true); + librefmToggle.checked = true; + alert(`Successfully connected to Libre.fm as ${result.username}!`); + } + } catch { + // Still waiting + } + }, 2000); + } catch (error) { + console.error('Libre.fm connection failed:', error); + alert('Failed to connect to Libre.fm: ' + error.message); + librefmConnectBtn.textContent = 'Connect Libre.fm'; + librefmConnectBtn.disabled = false; + if (authWindow && !authWindow.closed) authWindow.close(); + } + }); + + // Libre.fm Toggles + if (librefmToggle) { + librefmToggle.addEventListener('change', (e) => { + libreFmSettings.setEnabled(e.target.checked); + }); + } + + if (librefmLoveToggle) { + librefmLoveToggle.addEventListener('change', (e) => { + libreFmSettings.setLoveOnLike(e.target.checked); + }); + } + } + // Theme picker const themePicker = document.getElementById('theme-picker'); const currentTheme = themeManager.getTheme(); diff --git a/js/storage.js b/js/storage.js index 4ff8211..c551ad1 100644 --- a/js/storage.js +++ b/js/storage.js @@ -902,6 +902,7 @@ export const queueManager = { export const listenBrainzSettings = { ENABLED_KEY: 'listenbrainz-enabled', TOKEN_KEY: 'listenbrainz-token', + CUSTOM_URL_KEY: 'listenbrainz-custom-url', isEnabled() { try { @@ -926,6 +927,89 @@ export const listenBrainzSettings = { setToken(token) { localStorage.setItem(this.TOKEN_KEY, token); }, + + getCustomUrl() { + try { + return localStorage.getItem(this.CUSTOM_URL_KEY) || ''; + } catch { + return ''; + } + }, + + setCustomUrl(url) { + localStorage.setItem(this.CUSTOM_URL_KEY, url); + }, +}; + +export const malojaSettings = { + ENABLED_KEY: 'maloja-enabled', + TOKEN_KEY: 'maloja-token', + CUSTOM_URL_KEY: 'maloja-custom-url', + + isEnabled() { + try { + return localStorage.getItem(this.ENABLED_KEY) === 'true'; + } catch { + return false; + } + }, + + setEnabled(enabled) { + localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false'); + }, + + getToken() { + try { + return localStorage.getItem(this.TOKEN_KEY) || ''; + } catch { + return ''; + } + }, + + setToken(token) { + localStorage.setItem(this.TOKEN_KEY, token); + }, + + getCustomUrl() { + try { + return localStorage.getItem(this.CUSTOM_URL_KEY) || ''; + } catch { + return ''; + } + }, + + setCustomUrl(url) { + localStorage.setItem(this.CUSTOM_URL_KEY, url); + }, +}; + +export const libreFmSettings = { + ENABLED_KEY: 'librefm-enabled', + LOVE_ON_LIKE_KEY: 'librefm-love-on-like', + + isEnabled() { + try { + return localStorage.getItem(this.ENABLED_KEY) === 'true'; + } catch { + return false; + } + }, + + setEnabled(enabled) { + localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false'); + }, + + shouldLoveOnLike() { + try { + return localStorage.getItem(this.LOVE_ON_LIKE_KEY) === 'true'; + } catch { + return false; + } + }, + + setLoveOnLike(enabled) { + localStorage.setItem(this.LOVE_ON_LIKE_KEY, enabled ? 'true' : 'false'); + }, }; export const homePageSettings = { diff --git a/package-lock.json b/package-lock.json index da7d589..2c81c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1604,7 +1603,6 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1646,7 +1644,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1690,7 +1687,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3142,7 +3138,6 @@ "resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz", "integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=20" }, @@ -3191,7 +3186,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3215,7 +3209,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3503,7 +3496,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4288,7 +4280,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6645,7 +6636,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6729,7 +6719,6 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7679,7 +7668,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -8094,7 +8082,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -8419,7 +8406,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8807,7 +8793,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" },