Merge branch 'main' into feature/tabbed-settings

This commit is contained in:
Eduard Prigoana 2026-02-04 12:49:07 +02:00 committed by GitHub
commit 617da19f02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 914 additions and 23 deletions

View file

@ -755,7 +755,7 @@
</div>
<nav class="sidebar-nav main">
<ul>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-home">
<a href="/">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -777,7 +777,7 @@
<span>Home</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-library">
<a href="/library">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -798,7 +798,7 @@
<span>Library</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-recent">
<a href="/recent">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -818,7 +818,7 @@
<span>Recent</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-unreleased">
<a href="/unreleased">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -839,7 +839,32 @@
<span>Unreleased</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-donate">
<a href="https://pally.gg/p/monochrome" target="_blank" rel="noopener noreferrer">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 14h2a2 2 0 0 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 16" />
<path
d="m14.45 13.39 5.05-4.694C20.196 8 21 6.85 21 5.75a2.75 2.75 0 0 0-4.797-1.837.276.276 0 0 1-.406 0A2.75 2.75 0 0 0 11 5.75c0 1.2.802 2.248 1.5 2.946L16 11.95"
/>
<path d="m2 15 6 6" />
<path
d="m7 20 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a1 1 0 0 0-2.75-2.91"
/>
</svg>
<span>Donate</span>
</a>
</li>
<li class="nav-item" id="sidebar-nav-settings">
<a href="/settings">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -860,7 +885,7 @@
<span>Settings</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-account">
<a href="/account">
<svg
width="24"
@ -898,7 +923,7 @@
</nav>
<nav class="sidebar-nav">
<ul>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-about">
<a href="/about">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -919,7 +944,7 @@
<span>About</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-download">
<a href="/download">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -939,7 +964,7 @@
<span>Download</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" id="sidebar-nav-discord">
<a href="https://monochrome.samidy.com/discord" target="_blank">
<svg
width="64px"
@ -1378,7 +1403,31 @@
</div>
<div id="page-recent" class="page">
<h2 class="section-title">Recently played</h2>
<div
style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem"
>
<h2 class="section-title" style="margin-bottom: 0">Recently played</h2>
<button class="btn-secondary" id="clear-history-btn" title="Clear History">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
<span>Clear</span>
</button>
</div>
<div class="track-list" id="recent-tracks-container"></div>
</div>
@ -2083,6 +2132,164 @@
<input type="checkbox" id="listenbrainz-enabled-toggle" />
<span class="slider"></span>
</label>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Show Recommended Songs</span>
<span class="description">Display recommended songs on the home page</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="show-recommended-songs-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Recommended Albums</span>
<span class="description">Display recommended albums on the home page</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="show-recommended-albums-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Recommended Artists</span>
<span class="description">Display recommended artists on the home page</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="show-recommended-artists-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Jump Back In</span>
<span class="description"
>Display recent albums, playlists, and mixes on the home page</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="show-jump-back-in-toggle" checked />
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Show Home in Sidebar</span>
<span class="description">Display the Home link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-home-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Library in Sidebar</span>
<span class="description">Display the Library link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-library-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Recent in Sidebar</span>
<span class="description">Display the Recent link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-recent-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Unreleased in Sidebar</span>
<span class="description"
>Display the Unreleased link in the sidebar navigation</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-unreleased-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Donate in Sidebar</span>
<span class="description">Display the Donate link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-donate-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Settings in Sidebar</span>
<span class="description">Display the Settings link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-settings-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Account in Sidebar</span>
<span class="description">Display the Account link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-account-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show About in Sidebar</span>
<span class="description">Display the About link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-about-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Download in Sidebar</span>
<span class="description">Display the Download link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-download-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Discord in Sidebar</span>
<span class="description">Display the Discord link in the sidebar navigation</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-discord-toggle" checked />
<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>
<div class="setting-item" id="listenbrainz-token-setting" style="display: none">
<div class="info">
@ -2809,7 +3016,7 @@
flex-wrap: wrap;
"
>
<a href="https://ko-fi.com/monochromemusic">
<a href="https://pally.gg/p/monochrome">
<button id="donate-btn" class="btn-secondary">Donate to Monochrome</button>
</a>
</div>

View file

@ -98,6 +98,26 @@ const syncManager = {
});
return JSON.parse(recovered);
} catch {
try {
// Python-style fallback (Single quotes, True/False, None)
// This handles data that was incorrectly serialized as Python repr string
if (str.includes("'") || str.includes('True') || str.includes('False')) {
const jsFriendly = str
.replace(/\bTrue\b/g, 'true')
.replace(/\bFalse\b/g, 'false')
.replace(/\bNone\b/g, 'null');
// Basic safety check: ensure it looks like a structure and doesn't contain obvious code vectors
if (
(jsFriendly.trim().startsWith('[') || jsFriendly.trim().startsWith('{')) &&
!jsFriendly.match(/function|=>|window|document|alert|eval/)
) {
return new Function('return ' + jsFriendly)();
}
}
} catch (e) {
// Ignore fallback error
}
return fallback;
}
}
@ -361,7 +381,7 @@ const syncManager = {
image: playlist.cover,
cover: playlist.cover,
playlist_cover: playlist.cover,
tracks: playlist.tracks,
tracks: JSON.stringify(playlist.tracks || []),
isPublic: true,
data: {
title: playlist.name,

View file

@ -1,6 +1,6 @@
//js/app.js
import { LosslessAPI } from './api.js';
import { apiSettings, themeManager, nowPlayingSettings, downloadQualitySettings } from './storage.js';
import { apiSettings, themeManager, nowPlayingSettings, downloadQualitySettings, sidebarSettings } from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import { MultiScrobbler } from './multi-scrobbler.js';
@ -313,6 +313,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const currentTheme = themeManager.getTheme();
themeManager.setTheme(currentTheme);
// Restore sidebar state
sidebarSettings.restoreState();
initializeSettings(scrobbler, player, api, ui);
initializePlayerEvents(player, audioPlayer, scrobbler, ui);
initializeTrackInteractions(
@ -405,6 +408,8 @@ document.addEventListener('DOMContentLoaded', async () => {
? '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>';
}
// Save sidebar state to localStorage
sidebarSettings.setCollapsed(isCollapsed);
});
document.getElementById('nav-back')?.addEventListener('click', () => {

View file

@ -98,6 +98,17 @@ class AudioContextManager {
if (this.isInitialized) return;
if (!audioElement) return;
// Detect iOS - skip Web Audio initialization on iOS to avoid lock screen audio issues
// iOS suspends AudioContext when screen locks, and MediaSession controls don't count
// as user gestures to resume it, causing audio to play silently
const ua = navigator.userAgent.toLowerCase();
const isIOS = /iphone|ipad|ipod/.test(ua) || (ua.includes('mac') && navigator.maxTouchPoints > 1);
if (isIOS) {
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility');
this.isInitialized = true; // Mark as initialized to prevent repeated attempts
return;
}
try {
this.audio = audioElement;
@ -179,11 +190,28 @@ class AudioContextManager {
/**
* Resume audio context (required after user interaction)
* @returns {Promise<boolean>} - Returns true if context is running
*/
resume() {
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
async resume() {
if (!this.audioContext) return false;
console.log('[AudioContext] Current state:', this.audioContext.state);
if (this.audioContext.state === 'suspended') {
try {
await this.audioContext.resume();
console.log('[AudioContext] Resumed successfully, state:', this.audioContext.state);
} catch (e) {
console.warn('[AudioContext] Failed to resume:', e);
}
}
// Ensure graph is connected after resuming (iOS may disconnect when suspended)
if (this.isInitialized && this.audioContext.state === 'running') {
this._connectGraph();
}
return this.audioContext.state === 'running';
}
/**

View file

@ -138,6 +138,19 @@ export class MusicDatabase {
});
}
async clearHistory() {
const storeName = 'history_tracks';
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Favorites API
async toggleFavorite(type, item) {
const plural = type === 'mix' ? 'mixes' : `${type}s`;

View file

@ -133,6 +133,40 @@ export class LyricsManager {
this.geniusManager = new GeniusManager();
this.isGeniusMode = false;
this.currentGeniusData = null;
this.timingOffset = 0; // Offset in milliseconds (positive = delay lyrics, negative = advance lyrics)
}
// Get timing offset for current track
getTimingOffset(trackId) {
try {
const key = `lyrics-offset-${trackId}`;
const stored = localStorage.getItem(key);
return stored ? parseInt(stored, 10) : 0;
} catch {
return 0;
}
}
// Set timing offset for current track
setTimingOffset(trackId, offsetMs) {
try {
const key = `lyrics-offset-${trackId}`;
localStorage.setItem(key, offsetMs.toString());
} catch (e) {
console.warn('Failed to save lyrics timing offset:', e);
}
}
// Reset timing offset for current track
resetTimingOffset(trackId) {
this.setTimingOffset(trackId, 0);
}
// Get formatted offset display string
getOffsetDisplayString(offsetMs) {
const sign = offsetMs >= 0 ? '+' : '';
const seconds = Math.abs(offsetMs) / 1000;
return `${sign}${seconds.toFixed(1)}s`;
}
// Load Kuroshiro from CDN (npm package uses Node.js path which doesn't work in browser)
@ -715,15 +749,38 @@ export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = f
});
}
// Load saved timing offset for this track
manager.timingOffset = manager.getTimingOffset(track.id);
const renderControls = (container) => {
const isRomajiMode = manager.getRomajiMode();
manager.isRomajiMode = isRomajiMode;
const isGeniusMode = manager.isGeniusMode;
const offsetDisplay = manager.getOffsetDisplayString(manager.timingOffset);
container.innerHTML = `
<button id="close-side-panel-btn" class="btn-icon" title="Close">
${SVG_CLOSE}
</button>
<div class="lyrics-timing-controls">
<button id="lyrics-timing-minus-btn" class="btn-icon" title="Decrease delay (lyrics earlier) -0.5s">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
</svg>
</button>
<span id="lyrics-timing-display" class="lyrics-timing-display" title="Current timing offset">${offsetDisplay}</span>
<button id="lyrics-timing-plus-btn" class="btn-icon" title="Increase delay (lyrics later) +0.5s">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5v14"/>
</svg>
</button>
<button id="lyrics-timing-reset-btn" class="btn-icon" title="Reset timing offset">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
</button>
</div>
<button id="romaji-toggle-btn" class="btn-icon" title="Toggle Romaji (Japanese to Latin)" data-enabled="${isRomajiMode}" style="color: ${isRomajiMode ? 'var(--primary)' : ''}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
@ -740,6 +797,32 @@ export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = f
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
});
// Timing adjustment controls
const updateTimingDisplay = () => {
const display = container.querySelector('#lyrics-timing-display');
if (display) {
display.textContent = manager.getOffsetDisplayString(manager.timingOffset);
}
};
container.querySelector('#lyrics-timing-minus-btn')?.addEventListener('click', () => {
manager.timingOffset -= 500; // Decrease by 0.5 seconds
manager.setTimingOffset(track.id, manager.timingOffset);
updateTimingDisplay();
});
container.querySelector('#lyrics-timing-plus-btn')?.addEventListener('click', () => {
manager.timingOffset += 500; // Increase by 0.5 seconds
manager.setTimingOffset(track.id, manager.timingOffset);
updateTimingDisplay();
});
container.querySelector('#lyrics-timing-reset-btn')?.addEventListener('click', () => {
manager.timingOffset = 0;
manager.resetTimingOffset(track.id);
updateTimingDisplay();
});
// Romaji toggle button handler
const romajiBtn = container.querySelector('#romaji-toggle-btn');
if (romajiBtn) {
@ -945,11 +1028,17 @@ function setupSync(track, audioPlayer, amLyrics, lyricsManager) {
let lastTimestamp = performance.now();
let animationFrameId = null;
// Get timing offset from lyrics manager (in milliseconds)
const getTimingOffset = () => {
return lyricsManager?.timingOffset || 0;
};
const updateTime = () => {
const currentMs = audioPlayer.currentTime * 1000;
baseTimeMs = currentMs;
lastTimestamp = performance.now();
amLyrics.currentTime = currentMs;
// Apply timing offset: positive offset delays lyrics, negative advances them
amLyrics.currentTime = currentMs - getTimingOffset();
};
const tick = () => {
@ -957,7 +1046,8 @@ function setupSync(track, audioPlayer, amLyrics, lyricsManager) {
const now = performance.now();
const elapsed = now - lastTimestamp;
const nextMs = baseTimeMs + elapsed;
amLyrics.currentTime = nextMs;
// Apply timing offset: positive offset delays lyrics, negative advances them
amLyrics.currentTime = nextMs - getTimingOffset();
animationFrameId = requestAnimationFrame(tick);
}
};

View file

@ -9,6 +9,7 @@ import {
createQualityBadgeHTML,
} from './utils.js';
import { queueManager, replayGainSettings } from './storage.js';
import { audioContextManager } from './audio-context.js';
export class Player {
constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') {
@ -50,6 +51,17 @@ export class Player {
window.addEventListener('beforeunload', () => {
this.saveQueueState();
});
// Handle visibility change for iOS - AudioContext gets suspended when screen locks
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !this.audio.paused) {
// Ensure audio context is resumed when user returns to the app
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
}
audioContextManager.resume();
}
});
}
setVolume(value) {
@ -176,19 +188,42 @@ export class Player {
setupMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => {
this.audio.play().catch(console.error);
navigator.mediaSession.setActionHandler('play', async () => {
// Initialize and resume audio context first (required for iOS lock screen)
// Must happen before audio.play() or audio won't route through Web Audio
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
}
await audioContextManager.resume();
try {
await this.audio.play();
} catch (e) {
console.error('MediaSession play failed:', e);
// If play fails, try to handle it like a regular play/pause
this.handlePlayPause();
}
});
navigator.mediaSession.setActionHandler('pause', () => {
this.audio.pause();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
navigator.mediaSession.setActionHandler('previoustrack', async () => {
// Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
}
await audioContextManager.resume();
this.playPrev();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
navigator.mediaSession.setActionHandler('nexttrack', async () => {
// Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio);
}
await audioContextManager.resume();
this.playNext();
});

View file

@ -17,6 +17,8 @@ import {
playlistSettings,
equalizerSettings,
listenBrainzSettings,
homePageSettings,
sidebarSectionSettings,
} from './storage.js';
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
import { db } from './db.js';
@ -676,6 +678,133 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
// Home Page Section Toggles
const showRecommendedSongsToggle = document.getElementById('show-recommended-songs-toggle');
if (showRecommendedSongsToggle) {
showRecommendedSongsToggle.checked = homePageSettings.shouldShowRecommendedSongs();
showRecommendedSongsToggle.addEventListener('change', (e) => {
homePageSettings.setShowRecommendedSongs(e.target.checked);
});
}
const showRecommendedAlbumsToggle = document.getElementById('show-recommended-albums-toggle');
if (showRecommendedAlbumsToggle) {
showRecommendedAlbumsToggle.checked = homePageSettings.shouldShowRecommendedAlbums();
showRecommendedAlbumsToggle.addEventListener('change', (e) => {
homePageSettings.setShowRecommendedAlbums(e.target.checked);
});
}
const showRecommendedArtistsToggle = document.getElementById('show-recommended-artists-toggle');
if (showRecommendedArtistsToggle) {
showRecommendedArtistsToggle.checked = homePageSettings.shouldShowRecommendedArtists();
showRecommendedArtistsToggle.addEventListener('change', (e) => {
homePageSettings.setShowRecommendedArtists(e.target.checked);
});
}
const showJumpBackInToggle = document.getElementById('show-jump-back-in-toggle');
if (showJumpBackInToggle) {
showJumpBackInToggle.checked = homePageSettings.shouldShowJumpBackIn();
showJumpBackInToggle.addEventListener('change', (e) => {
homePageSettings.setShowJumpBackIn(e.target.checked);
});
}
// Sidebar Section Toggles
const sidebarShowHomeToggle = document.getElementById('sidebar-show-home-toggle');
if (sidebarShowHomeToggle) {
sidebarShowHomeToggle.checked = sidebarSectionSettings.shouldShowHome();
sidebarShowHomeToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowHome(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowLibraryToggle = document.getElementById('sidebar-show-library-toggle');
if (sidebarShowLibraryToggle) {
sidebarShowLibraryToggle.checked = sidebarSectionSettings.shouldShowLibrary();
sidebarShowLibraryToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowLibrary(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowRecentToggle = document.getElementById('sidebar-show-recent-toggle');
if (sidebarShowRecentToggle) {
sidebarShowRecentToggle.checked = sidebarSectionSettings.shouldShowRecent();
sidebarShowRecentToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowRecent(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowUnreleasedToggle = document.getElementById('sidebar-show-unreleased-toggle');
if (sidebarShowUnreleasedToggle) {
sidebarShowUnreleasedToggle.checked = sidebarSectionSettings.shouldShowUnreleased();
sidebarShowUnreleasedToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowUnreleased(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowDonateToggle = document.getElementById('sidebar-show-donate-toggle');
if (sidebarShowDonateToggle) {
sidebarShowDonateToggle.checked = sidebarSectionSettings.shouldShowDonate();
sidebarShowDonateToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowDonate(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowSettingsToggle = document.getElementById('sidebar-show-settings-toggle');
if (sidebarShowSettingsToggle) {
sidebarShowSettingsToggle.checked = sidebarSectionSettings.shouldShowSettings();
sidebarShowSettingsToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowSettings(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowAccountToggle = document.getElementById('sidebar-show-account-toggle');
if (sidebarShowAccountToggle) {
sidebarShowAccountToggle.checked = sidebarSectionSettings.shouldShowAccount();
sidebarShowAccountToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowAccount(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowAboutToggle = document.getElementById('sidebar-show-about-toggle');
if (sidebarShowAboutToggle) {
sidebarShowAboutToggle.checked = sidebarSectionSettings.shouldShowAbout();
sidebarShowAboutToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowAbout(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowDownloadToggle = document.getElementById('sidebar-show-download-toggle');
if (sidebarShowDownloadToggle) {
sidebarShowDownloadToggle.checked = sidebarSectionSettings.shouldShowDownload();
sidebarShowDownloadToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowDownload(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
const sidebarShowDiscordToggle = document.getElementById('sidebar-show-discord-toggle');
if (sidebarShowDiscordToggle) {
sidebarShowDiscordToggle.checked = sidebarSectionSettings.shouldShowDiscord();
sidebarShowDiscordToggle.addEventListener('change', (e) => {
sidebarSectionSettings.setShowDiscord(e.target.checked);
sidebarSectionSettings.applySidebarVisibility();
});
}
// Apply sidebar visibility on initialization
sidebarSectionSettings.applySidebarVisibility();
// Filename template setting
const filenameTemplate = document.getElementById('filename-template');
if (filenameTemplate) {

View file

@ -814,6 +814,34 @@ export const equalizerSettings = {
},
};
export const sidebarSettings = {
STORAGE_KEY: 'monochrome-sidebar-collapsed',
isCollapsed() {
try {
return localStorage.getItem(this.STORAGE_KEY) === 'true';
} catch {
return false;
}
},
setCollapsed(collapsed) {
localStorage.setItem(this.STORAGE_KEY, collapsed ? 'true' : 'false');
},
restoreState() {
const isCollapsed = this.isCollapsed();
if (isCollapsed) {
document.body.classList.add('sidebar-collapsed');
const toggleBtn = document.getElementById('sidebar-toggle');
if (toggleBtn) {
toggleBtn.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>';
}
}
},
};
export const queueManager = {
STORAGE_KEY: 'monochrome-queue',
@ -873,6 +901,230 @@ export const listenBrainzSettings = {
},
};
export const homePageSettings = {
SHOW_RECOMMENDED_SONGS_KEY: 'home-show-recommended-songs',
SHOW_RECOMMENDED_ALBUMS_KEY: 'home-show-recommended-albums',
SHOW_RECOMMENDED_ARTISTS_KEY: 'home-show-recommended-artists',
SHOW_JUMP_BACK_IN_KEY: 'home-show-jump-back-in',
shouldShowRecommendedSongs() {
try {
const val = localStorage.getItem(this.SHOW_RECOMMENDED_SONGS_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowRecommendedSongs(enabled) {
localStorage.setItem(this.SHOW_RECOMMENDED_SONGS_KEY, enabled ? 'true' : 'false');
},
shouldShowRecommendedAlbums() {
try {
const val = localStorage.getItem(this.SHOW_RECOMMENDED_ALBUMS_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowRecommendedAlbums(enabled) {
localStorage.setItem(this.SHOW_RECOMMENDED_ALBUMS_KEY, enabled ? 'true' : 'false');
},
shouldShowRecommendedArtists() {
try {
const val = localStorage.getItem(this.SHOW_RECOMMENDED_ARTISTS_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowRecommendedArtists(enabled) {
localStorage.setItem(this.SHOW_RECOMMENDED_ARTISTS_KEY, enabled ? 'true' : 'false');
},
shouldShowJumpBackIn() {
try {
const val = localStorage.getItem(this.SHOW_JUMP_BACK_IN_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowJumpBackIn(enabled) {
localStorage.setItem(this.SHOW_JUMP_BACK_IN_KEY, enabled ? 'true' : 'false');
},
};
export const sidebarSectionSettings = {
SHOW_HOME_KEY: 'sidebar-show-home',
SHOW_LIBRARY_KEY: 'sidebar-show-library',
SHOW_RECENT_KEY: 'sidebar-show-recent',
SHOW_UNRELEASED_KEY: 'sidebar-show-unreleased',
SHOW_DONATE_KEY: 'sidebar-show-donate',
SHOW_SETTINGS_KEY: 'sidebar-show-settings',
SHOW_ACCOUNT_KEY: 'sidebar-show-account',
SHOW_ABOUT_KEY: 'sidebar-show-about',
SHOW_DOWNLOAD_KEY: 'sidebar-show-download',
SHOW_DISCORD_KEY: 'sidebar-show-discord',
shouldShowHome() {
try {
const val = localStorage.getItem(this.SHOW_HOME_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowHome(enabled) {
localStorage.setItem(this.SHOW_HOME_KEY, enabled ? 'true' : 'false');
},
shouldShowLibrary() {
try {
const val = localStorage.getItem(this.SHOW_LIBRARY_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowLibrary(enabled) {
localStorage.setItem(this.SHOW_LIBRARY_KEY, enabled ? 'true' : 'false');
},
shouldShowRecent() {
try {
const val = localStorage.getItem(this.SHOW_RECENT_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowRecent(enabled) {
localStorage.setItem(this.SHOW_RECENT_KEY, enabled ? 'true' : 'false');
},
shouldShowUnreleased() {
try {
const val = localStorage.getItem(this.SHOW_UNRELEASED_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowUnreleased(enabled) {
localStorage.setItem(this.SHOW_UNRELEASED_KEY, enabled ? 'true' : 'false');
},
shouldShowDonate() {
try {
const val = localStorage.getItem(this.SHOW_DONATE_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowDonate(enabled) {
localStorage.setItem(this.SHOW_DONATE_KEY, enabled ? 'true' : 'false');
},
shouldShowSettings() {
try {
const val = localStorage.getItem(this.SHOW_SETTINGS_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowSettings(enabled) {
localStorage.setItem(this.SHOW_SETTINGS_KEY, enabled ? 'true' : 'false');
},
shouldShowAccount() {
try {
const val = localStorage.getItem(this.SHOW_ACCOUNT_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowAccount(enabled) {
localStorage.setItem(this.SHOW_ACCOUNT_KEY, enabled ? 'true' : 'false');
},
shouldShowAbout() {
try {
const val = localStorage.getItem(this.SHOW_ABOUT_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowAbout(enabled) {
localStorage.setItem(this.SHOW_ABOUT_KEY, enabled ? 'true' : 'false');
},
shouldShowDownload() {
try {
const val = localStorage.getItem(this.SHOW_DOWNLOAD_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowDownload(enabled) {
localStorage.setItem(this.SHOW_DOWNLOAD_KEY, enabled ? 'true' : 'false');
},
shouldShowDiscord() {
try {
const val = localStorage.getItem(this.SHOW_DISCORD_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setShowDiscord(enabled) {
localStorage.setItem(this.SHOW_DISCORD_KEY, enabled ? 'true' : 'false');
},
applySidebarVisibility() {
const items = [
{ id: 'sidebar-nav-home', check: this.shouldShowHome() },
{ id: 'sidebar-nav-library', check: this.shouldShowLibrary() },
{ id: 'sidebar-nav-recent', check: this.shouldShowRecent() },
{ id: 'sidebar-nav-unreleased', check: this.shouldShowUnreleased() },
{ id: 'sidebar-nav-donate', check: this.shouldShowDonate() },
{ id: 'sidebar-nav-settings', check: this.shouldShowSettings() },
{ id: 'sidebar-nav-account', check: this.shouldShowAccount() },
{ id: 'sidebar-nav-about', check: this.shouldShowAbout() },
{ id: 'sidebar-nav-download', check: this.shouldShowDownload() },
{ id: 'sidebar-nav-discord', check: this.shouldShowDiscord() },
];
items.forEach(({ id, check }) => {
const el = document.getElementById(id);
if (el) {
el.style.display = check ? '' : 'none';
}
});
},
};
// System theme listener
if (typeof window !== 'undefined' && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {

View file

@ -19,7 +19,13 @@ import {
escapeHtml,
} from './utils.js';
import { openLyricsPanel } from './lyrics.js';
import { recentActivityManager, backgroundSettings, cardSettings, visualizerSettings } from './storage.js';
import {
recentActivityManager,
backgroundSettings,
cardSettings,
visualizerSettings,
homePageSettings,
} from './storage.js';
import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js';
import { syncManager } from './accounts/pocketbase.js';
@ -1254,6 +1260,15 @@ export class UIRenderer {
async renderHomeSongs(forceRefresh = false) {
const songsContainer = document.getElementById('home-recommended-songs');
const section = songsContainer?.closest('.content-section');
if (!homePageSettings.shouldShowRecommendedSongs()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (songsContainer) {
if (forceRefresh) songsContainer.innerHTML = this.createSkeletonTracks(5, true);
else if (songsContainer.children.length > 0 && !songsContainer.querySelector('.skeleton')) return; // Already loaded
@ -1279,6 +1294,15 @@ export class UIRenderer {
async renderHomeAlbums(forceRefresh = false) {
const albumsContainer = document.getElementById('home-recommended-albums');
const section = albumsContainer?.closest('.content-section');
if (!homePageSettings.shouldShowRecommendedAlbums()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (albumsContainer) {
if (forceRefresh) albumsContainer.innerHTML = this.createSkeletonCards(6);
else if (albumsContainer.children.length > 0 && !albumsContainer.querySelector('.skeleton')) return;
@ -1317,6 +1341,15 @@ export class UIRenderer {
async renderHomeArtists(forceRefresh = false) {
const artistsContainer = document.getElementById('home-recommended-artists');
const section = artistsContainer?.closest('.content-section');
if (!homePageSettings.shouldShowRecommendedArtists()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (artistsContainer) {
if (forceRefresh) artistsContainer.innerHTML = this.createSkeletonCards(6, true);
else if (artistsContainer.children.length > 0 && !artistsContainer.querySelector('.skeleton')) return;
@ -1359,6 +1392,15 @@ export class UIRenderer {
renderHomeRecent() {
const recentContainer = document.getElementById('home-recent-mixed');
const section = recentContainer?.closest('.content-section');
if (!homePageSettings.shouldShowJumpBackIn()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (recentContainer) {
const recents = recentActivityManager.getRecents();
const items = [];
@ -2513,11 +2555,17 @@ export class UIRenderer {
async renderRecentPage() {
this.showPage('recent');
const container = document.getElementById('recent-tracks-container');
const clearBtn = document.getElementById('clear-history-btn');
container.innerHTML = this.createSkeletonTracks(10, true);
try {
const history = await db.getHistory();
// Show/hide clear button based on whether there's history
if (clearBtn) {
clearBtn.style.display = history.length > 0 ? 'flex' : 'none';
}
if (history.length === 0) {
container.innerHTML = createPlaceholder("You haven't played any tracks yet.");
return;
@ -2570,9 +2618,26 @@ export class UIRenderer {
container.appendChild(tempContainer.firstChild);
}
}
// Setup clear button handler
if (clearBtn) {
clearBtn.onclick = async () => {
if (confirm('Clear all recently played tracks? This cannot be undone.')) {
try {
await db.clearHistory();
container.innerHTML = createPlaceholder("You haven't played any tracks yet.");
clearBtn.style.display = 'none';
} catch (err) {
console.error('Failed to clear history:', err);
alert('Failed to clear history');
}
}
};
}
} catch (error) {
console.error('Failed to load history:', error);
container.innerHTML = createPlaceholder('Failed to load history.');
if (clearBtn) clearBtn.style.display = 'none';
}
}

15
package-lock.json generated
View file

@ -74,6 +74,7 @@
"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",
@ -1603,6 +1604,7 @@
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@ -1644,6 +1646,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1687,6 +1690,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -3138,6 +3142,7 @@
"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"
},
@ -3186,6 +3191,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3209,6 +3215,7 @@
"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",
@ -3496,6 +3503,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -4280,6 +4288,7 @@
"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",
@ -6636,6 +6645,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -6719,6 +6729,7 @@
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -7668,6 +7679,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
@ -8082,6 +8094,7 @@
"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",
@ -8406,6 +8419,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -8793,6 +8807,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},

View file

@ -3305,6 +3305,38 @@ input:checked + .slider::before {
gap: 0.5rem;
}
/* Lyrics timing adjustment controls */
.lyrics-timing-controls {
display: flex;
align-items: center;
gap: 0.25rem;
margin-right: auto;
padding-right: 0.5rem;
border-right: 1px solid var(--border);
}
.lyrics-timing-display {
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--foreground);
min-width: 3.5rem;
text-align: center;
user-select: none;
cursor: default;
}
.lyrics-timing-controls .btn-icon {
padding: 0.4rem;
width: 28px;
height: 28px;
}
.lyrics-timing-controls .btn-icon:hover {
background: var(--secondary);
color: var(--primary);
}
.panel-content {
flex: 1;
overflow-y: auto;