From 377adc8f0a3a745144f08936c221b89d7c1172fc Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 3 Feb 2026 13:46:34 +0000 Subject: [PATCH 01/10] fix iOS background play --- js/audio-context.js | 34 +++++++++++++++++++++++++++++++--- js/player.js | 43 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/js/audio-context.js b/js/audio-context.js index ef1375f..5f1c2e6 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -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; @@ -177,11 +188,28 @@ class AudioContextManager { /** * Resume audio context (required after user interaction) + * @returns {Promise} - 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'; } /** diff --git a/js/player.js b/js/player.js index 9618bf1..bea2daf 100644 --- a/js/player.js +++ b/js/player.js @@ -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(); }); From a62b054e0bb6421279bd2d9ba48a85d670b8be71 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 3 Feb 2026 17:52:37 +0200 Subject: [PATCH 02/10] remember sidebar collapse state --- package-lock.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/package-lock.json b/package-lock.json index 2c81c15..da7d589 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, From b59c85e108a621b9061cc2c2ee83e6f9b8056c8d Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 3 Feb 2026 17:52:59 +0200 Subject: [PATCH 03/10] oops --- js/app.js | 7 ++++++- js/storage.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index 6fdfd35..c50caf5 100644 --- a/js/app.js +++ b/js/app.js @@ -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 () => { ? '' : ''; } + // Save sidebar state to localStorage + sidebarSettings.setCollapsed(isCollapsed); }); document.getElementById('nav-back')?.addEventListener('click', () => { diff --git a/js/storage.js b/js/storage.js index 5990ff8..9a369e1 100644 --- a/js/storage.js +++ b/js/storage.js @@ -812,6 +812,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 = + ''; + } + } + }, +}; + export const queueManager = { STORAGE_KEY: 'monochrome-queue', From a25f05a66ea716e540320f18d56b4e7a3d9cfd84 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 3 Feb 2026 17:58:40 +0200 Subject: [PATCH 04/10] lyrics offset --- js/lyrics.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++-- styles.css | 32 ++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/js/lyrics.js b/js/lyrics.js index 53ed59b..785429b 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -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 = ` +
+ + ${offsetDisplay} + + +
+
diff --git a/js/db.js b/js/db.js index 7e581ab..719348e 100644 --- a/js/db.js +++ b/js/db.js @@ -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`; diff --git a/js/ui.js b/js/ui.js index d7cf94e..6742206 100644 --- a/js/ui.js +++ b/js/ui.js @@ -2511,11 +2511,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; @@ -2568,9 +2574,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'; } } From 03b5bebebf06530c40cee31cdd73846d0f60386a Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 3 Feb 2026 17:28:22 +0000 Subject: [PATCH 06/10] donate button --- index.html | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 5b240df..0a920bf 100644 --- a/index.html +++ b/index.html @@ -839,6 +839,31 @@ Unreleased +