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"
/>
+
+
+ Custom API URL (Optional)
+ Leave empty to use official ListenBrainz server
+
+
+
@@ -2354,6 +2367,83 @@
+
+
+
+
+ Maloja Scrobbling
+ Submit listens to a self-hosted Maloja server
+
+
+
+
+
+ API Key
+ Found in your Maloja settings
+
+
+
+
+
+ Maloja Server URL
+ Your Maloja instance URL
+
+
+
+
+
+
+
+
+ Libre.fm Scrobbling
+ Connect your Libre.fm account to scrobble tracks
+
+
+
+
+
+
+
+
+ Enable Scrobbling
+ Automatically scrobble played tracks
+
+
+
+
+
+
+ Love on Like
+ Automatically 'love' tracks on Libre.fm when you like them
+
+
+
+
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"
},