scrobbling and skeleton changes

This commit is contained in:
Eduard Prigoana 2026-02-08 22:36:07 +00:00
parent 09a60753f1
commit 2522e0e5be
9 changed files with 224 additions and 90 deletions

View file

@ -2396,41 +2396,40 @@
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">ListenBrainz Scrobbling</span>
<span class="label">Scrobble Threshold</span>
<span class="description"
>Submit listens to ListenBrainz (requires User Token)</span
>Percentage of track to play before scrobbling (1-100%)</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="listenbrainz-enabled-toggle" />
<span class="slider"></span>
</label>
</div>
<div class="setting-item" id="listenbrainz-token-setting" style="display: none">
<div class="info">
<span class="label">User Token</span>
<span class="description">Found on your ListenBrainz profile page</span>
<div style="display: flex; align-items: center; gap: 10px">
<input
type="range"
id="scrobble-percentage-slider"
min="1"
max="100"
step="1"
value="75"
style="width: 100px"
/>
<input
type="number"
id="scrobble-percentage-input"
min="1"
max="100"
value="75"
style="
width: 50px;
font-size: 0.9rem;
text-align: center;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--text-color);
"
/>
<span style="font-size: 0.9rem">%</span>
</div>
<input
type="password"
id="listenbrainz-token-input"
placeholder="Enter Token"
class="template-input"
style="width: 250px"
/>
</div>
<div class="setting-item" id="listenbrainz-custom-url-setting" style="display: none">
<div class="info">
<span class="label">Custom API URL (Optional)</span>
<span class="description">Leave empty to use official ListenBrainz server</span>
</div>
<input
type="url"
id="listenbrainz-custom-url-input"
placeholder="https://api.listenbrainz.org/1"
class="template-input"
style="width: 250px"
/>
</div>
</div>
@ -2472,6 +2471,85 @@
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Libre.fm Scrobbling</span>
<span class="description" id="librefm-status"
>Connect your Libre.fm account to scrobble tracks</span
>
</div>
<div id="librefm-controls">
<button id="librefm-connect-btn" class="btn-secondary">Connect Libre.fm</button>
</div>
</div>
<div class="setting-item" id="librefm-toggle-setting" style="display: none">
<div class="info">
<span class="label">Enable Scrobbling</span>
<span class="description">Automatically scrobble played tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="librefm-toggle" />
<span class="slider"></span>
</label>
</div>
<div class="setting-item" id="librefm-love-setting" style="display: none">
<div class="info">
<span class="label">Love on Like</span>
<span class="description"
>Automatically 'love' tracks on Libre.fm when you like them</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="librefm-love-toggle" />
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">ListenBrainz Scrobbling</span>
<span class="description"
>Submit listens to ListenBrainz (requires User Token)</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="listenbrainz-enabled-toggle" />
<span class="slider"></span>
</label>
</div>
<div class="setting-item" id="listenbrainz-token-setting" style="display: none">
<div class="info">
<span class="label">User Token</span>
<span class="description">Found on your ListenBrainz profile page</span>
</div>
<input
type="password"
id="listenbrainz-token-input"
placeholder="Enter Token"
class="template-input"
style="width: 250px"
/>
</div>
<div class="setting-item" id="listenbrainz-custom-url-setting" style="display: none">
<div class="info">
<span class="label">Custom API URL (Optional)</span>
<span class="description">Leave empty to use official ListenBrainz server</span>
</div>
<input
type="url"
id="listenbrainz-custom-url-input"
placeholder="https://api.listenbrainz.org/1"
class="template-input"
style="width: 250px"
/>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
@ -2510,44 +2588,6 @@
/>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Libre.fm Scrobbling</span>
<span class="description" id="librefm-status"
>Connect your Libre.fm account to scrobble tracks</span
>
</div>
<div id="librefm-controls">
<button id="librefm-connect-btn" class="btn-secondary">Connect Libre.fm</button>
</div>
</div>
<div class="setting-item" id="librefm-toggle-setting" style="display: none">
<div class="info">
<span class="label">Enable Scrobbling</span>
<span class="description">Automatically scrobble played tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="librefm-toggle" />
<span class="slider"></span>
</label>
</div>
<div class="setting-item" id="librefm-love-setting" style="display: none">
<div class="info">
<span class="label">Love on Like</span>
<span class="description"
>Automatically 'love' tracks on Libre.fm when you like them</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="librefm-love-toggle" />
<span class="slider"></span>
</label>
</div>
</div>
</div>
</div>
<div class="settings-tab-content" id="settings-tab-audio">

View file

@ -13,6 +13,7 @@ export class LastFMScrobbler {
this.scrobbleTimer = null;
this.scrobbleThreshold = 0;
this.hasScrobbled = false;
this.isScrobbling = false;
this.loadSession();
}
@ -178,7 +179,11 @@ export class LastFMScrobbler {
if (!this.isAuthenticated()) return;
this.currentTrack = track;
this.hasScrobbled = false;
// Only reset hasScrobbled if we're not currently in the middle of scrobbling
// to prevent race conditions that could cause double scrobbles
if (!this.isScrobbling) {
this.hasScrobbled = false;
}
this.clearScrobbleTimer();
try {
@ -204,7 +209,8 @@ export class LastFMScrobbler {
console.log('Now playing updated:', scrobbleTitle);
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
const scrobblePercentage = lastFMStorage.getScrobblePercentage() / 100;
this.scrobbleThreshold = Math.min(track.duration * scrobblePercentage, 240);
this.scheduleScrobble(this.scrobbleThreshold * 1000);
} catch (error) {
console.error('Failed to update now playing:', error);
@ -229,6 +235,8 @@ export class LastFMScrobbler {
async scrobbleCurrentTrack() {
if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return;
this.isScrobbling = true;
try {
const timestamp = Math.floor(Date.now() / 1000);
const scrobbleTitle = this.currentTrack.cleanTitle || this.currentTrack.title;
@ -257,6 +265,8 @@ export class LastFMScrobbler {
console.log('Scrobbled:', this.currentTrack.cleanTitle || this.currentTrack.title);
} catch (error) {
console.error('Failed to scrobble:', error);
} finally {
this.isScrobbling = false;
}
}

View file

@ -1,4 +1,4 @@
import { libreFmSettings } from './storage.js';
import { libreFmSettings, lastFMStorage } from './storage.js';
export class LibreFmScrobbler {
constructor() {
@ -12,6 +12,7 @@ export class LibreFmScrobbler {
this.scrobbleTimer = null;
this.scrobbleThreshold = 0;
this.hasScrobbled = false;
this.isScrobbling = false;
this.loadSession();
}
@ -174,7 +175,11 @@ export class LibreFmScrobbler {
if (!this.isAuthenticated()) return;
this.currentTrack = track;
this.hasScrobbled = false;
// Only reset hasScrobbled if we're not currently in the middle of scrobbling
// to prevent race conditions that could cause double scrobbles
if (!this.isScrobbling) {
this.hasScrobbled = false;
}
this.clearScrobbleTimer();
try {
@ -200,7 +205,8 @@ export class LibreFmScrobbler {
console.log('[Libre.fm] Now playing updated:', scrobbleTitle);
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
const scrobblePercentage = lastFMStorage.getScrobblePercentage() / 100;
this.scrobbleThreshold = Math.min(track.duration * scrobblePercentage, 240);
this.scheduleScrobble(this.scrobbleThreshold * 1000);
} catch (error) {
console.error('[Libre.fm] Failed to update now playing:', error);
@ -225,6 +231,8 @@ export class LibreFmScrobbler {
async scrobbleCurrentTrack() {
if (!this.isAuthenticated() || !this.currentTrack || this.hasScrobbled) return;
this.isScrobbling = true;
try {
const timestamp = Math.floor(Date.now() / 1000);
const scrobbleTitle = this.currentTrack.cleanTitle || this.currentTrack.title;
@ -253,6 +261,8 @@ export class LibreFmScrobbler {
console.log('[Libre.fm] Scrobbled:', this.currentTrack.cleanTitle || this.currentTrack.title);
} catch (error) {
console.error('[Libre.fm] Failed to scrobble:', error);
} finally {
this.isScrobbling = false;
}
}

View file

@ -1,4 +1,4 @@
import { listenBrainzSettings } from './storage.js';
import { listenBrainzSettings, lastFMStorage } from './storage.js';
export class ListenBrainzScrobbler {
constructor() {
@ -7,6 +7,7 @@ export class ListenBrainzScrobbler {
this.scrobbleTimer = null;
this.scrobbleThreshold = 0;
this.hasScrobbled = false;
this.isScrobbling = false;
}
getApiUrl() {
@ -120,12 +121,17 @@ export class ListenBrainzScrobbler {
if (!this.isEnabled()) return;
this.currentTrack = track;
this.hasScrobbled = false;
// Only reset hasScrobbled if we're not currently in the middle of scrobbling
// to prevent race conditions that could cause double scrobbles
if (!this.isScrobbling) {
this.hasScrobbled = false;
}
this.clearScrobbleTimer();
await this.submitListen('playing_now', track);
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
const scrobblePercentage = lastFMStorage.getScrobblePercentage() / 100;
this.scrobbleThreshold = Math.min(track.duration * scrobblePercentage, 240);
this.scheduleScrobble(this.scrobbleThreshold * 1000);
}
@ -146,9 +152,15 @@ export class ListenBrainzScrobbler {
async scrobbleCurrentTrack() {
if (!this.isEnabled() || !this.currentTrack || this.hasScrobbled) return;
const timestamp = Math.floor(Date.now() / 1000);
await this.submitListen('single', this.currentTrack, timestamp);
this.hasScrobbled = true;
this.isScrobbling = true;
try {
const timestamp = Math.floor(Date.now() / 1000);
await this.submitListen('single', this.currentTrack, timestamp);
this.hasScrobbled = true;
} finally {
this.isScrobbling = false;
}
}
onTrackChange(track) {

View file

@ -1,4 +1,5 @@
import { malojaSettings } from './storage.js';
import { lastFMStorage } from './storage.js';
export class MalojaScrobbler {
constructor() {
@ -6,6 +7,7 @@ export class MalojaScrobbler {
this.scrobbleTimer = null;
this.scrobbleThreshold = 0;
this.hasScrobbled = false;
this.isScrobbling = false;
}
getApiUrl() {
@ -115,14 +117,19 @@ export class MalojaScrobbler {
if (!this.isEnabled()) return;
this.currentTrack = track;
this.hasScrobbled = false;
// Only reset hasScrobbled if we're not currently in the middle of scrobbling
// to prevent race conditions that could cause double scrobbles
if (!this.isScrobbling) {
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);
const scrobblePercentage = lastFMStorage.getScrobblePercentage() / 100;
this.scrobbleThreshold = Math.min(track.duration * scrobblePercentage, 240);
this.scheduleScrobble(this.scrobbleThreshold * 1000);
}
@ -143,9 +150,15 @@ export class MalojaScrobbler {
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;
this.isScrobbling = true;
try {
const timestamp = Math.floor(Date.now() / 1000);
await this.submitScrobble(this.currentTrack, timestamp);
this.hasScrobbled = true;
} finally {
this.isScrobbling = false;
}
}
onTrackChange(track) {

View file

@ -220,6 +220,40 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
// ========================================
// Global Scrobble Settings
// ========================================
const scrobblePercentageSlider = document.getElementById('scrobble-percentage-slider');
const scrobblePercentageInput = document.getElementById('scrobble-percentage-input');
if (scrobblePercentageSlider && scrobblePercentageInput) {
const percentage = lastFMStorage.getScrobblePercentage();
scrobblePercentageSlider.value = percentage;
scrobblePercentageInput.value = percentage;
scrobblePercentageSlider.addEventListener('input', (e) => {
const newPercentage = parseInt(e.target.value, 10);
scrobblePercentageInput.value = newPercentage;
lastFMStorage.setScrobblePercentage(newPercentage);
});
scrobblePercentageInput.addEventListener('change', (e) => {
let newPercentage = parseInt(e.target.value, 10);
newPercentage = Math.max(1, Math.min(100, newPercentage || 75));
scrobblePercentageSlider.value = newPercentage;
scrobblePercentageInput.value = newPercentage;
lastFMStorage.setScrobblePercentage(newPercentage);
});
scrobblePercentageInput.addEventListener('input', (e) => {
let newPercentage = parseInt(e.target.value, 10);
if (!isNaN(newPercentage) && newPercentage >= 1 && newPercentage <= 100) {
scrobblePercentageSlider.value = newPercentage;
lastFMStorage.setScrobblePercentage(newPercentage);
}
});
}
// ========================================
// ListenBrainz Settings
// ========================================

View file

@ -279,6 +279,10 @@ export const themeManager = {
};
export const lastFMStorage = {
STORAGE_KEY: 'lastfm-enabled',
LOVE_ON_LIKE_KEY: 'lastfm-love-on-like',
SCROBBLE_PERCENTAGE_KEY: 'lastfm-scrobble-percentage',
isEnabled() {
try {
return localStorage.getItem(this.STORAGE_KEY) === 'true';
@ -302,6 +306,20 @@ export const lastFMStorage = {
setLoveOnLike(enabled) {
localStorage.setItem(this.LOVE_ON_LIKE_KEY, enabled ? 'true' : 'false');
},
getScrobblePercentage() {
try {
const value = localStorage.getItem(this.SCROBBLE_PERCENTAGE_KEY);
return value ? parseInt(value, 10) : 75;
} catch {
return 75;
}
},
setScrobblePercentage(percentage) {
const validPercentage = Math.max(1, Math.min(100, parseInt(percentage, 10) || 75));
localStorage.setItem(this.SCROBBLE_PERCENTAGE_KEY, validPercentage.toString());
},
};
export const nowPlayingSettings = {

View file

@ -566,10 +566,10 @@ export class UIRenderer {
}
createSkeletonCards(count = 6, isArtist = false) {
return `<div class="card-grid">${Array(count)
return Array(count)
.fill(0)
.map(() => this.createSkeletonCard(isArtist))
.join('')}</div>`;
.join('');
}
setupSearchClearButton(inputElement, clearBtnSelector = '.search-clear-btn') {

View file

@ -2,9 +2,6 @@
Sorted by ease of implementation (easiest to hardest):
- [x] Album click navigation: Clicking on the album in the player bar navigates to the album page (default behavior - can be changed in settings)
- [ ] Fix button overlap: Next track and casting buttons overlap on some screen resolutions
- [ ] Reduce API calls: Minimize unnecessary API calls throughout the app
- [ ] Editor's Picks: Create a JSON file of curated album IDs with metadata for the home screen. Include an option to disable in settings to avoid extra API calls.
- [ ] Update notifications: Add ability to show the update popup in settings, with an option to automatically update (enabled by default)