diff --git a/index.html b/index.html index ed4421a..03d194b 100644 --- a/index.html +++ b/index.html @@ -755,7 +755,7 @@ - + Home - + Library - + Recent - + Unreleased - + + + + + + + + + Donate + + + Settings - + - + About - + Download - + - Recently played + + Recently played + + + + + + + + + Clear + + @@ -2083,6 +2132,164 @@ + + + + + Show Recommended Songs + Display recommended songs on the home page + + + + + + + + + Show Recommended Albums + Display recommended albums on the home page + + + + + + + + + Show Recommended Artists + Display recommended artists on the home page + + + + + + + + + Show Jump Back In + Display recent albums, playlists, and mixes on the home page + + + + + + + + + + + + Show Home in Sidebar + Display the Home link in the sidebar navigation + + + + + + + + + Show Library in Sidebar + Display the Library link in the sidebar navigation + + + + + + + + + Show Recent in Sidebar + Display the Recent link in the sidebar navigation + + + + + + + + + Show Unreleased in Sidebar + Display the Unreleased link in the sidebar navigation + + + + + + + + + Show Donate in Sidebar + Display the Donate link in the sidebar navigation + + + + + + + + + Show Settings in Sidebar + Display the Settings link in the sidebar navigation + + + + + + + + + Show Account in Sidebar + Display the Account link in the sidebar navigation + + + + + + + + + Show About in Sidebar + Display the About link in the sidebar navigation + + + + + + + + + Show Download in Sidebar + Display the Download link in the sidebar navigation + + + + + + + + + Show Discord in Sidebar + Display the Discord link in the sidebar navigation + + + + + + + + + + + + ListenBrainz Scrobbling + Submit listens to ListenBrainz (requires User Token) @@ -2809,7 +3016,7 @@ flex-wrap: wrap; " > - + Donate to Monochrome diff --git a/js/accounts/pocketbase.js b/js/accounts/pocketbase.js index d40858f..2e1ebcd 100644 --- a/js/accounts/pocketbase.js +++ b/js/accounts/pocketbase.js @@ -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, 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/audio-context.js b/js/audio-context.js index a36a53a..61ca4b8 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; @@ -179,11 +190,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/db.js b/js/db.js index d8e1656..50d55d4 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/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 = ` ${SVG_CLOSE} + + + + + + + ${offsetDisplay} + + + + + + + + + + + + @@ -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); } }; 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(); }); diff --git a/js/settings.js b/js/settings.js index a30bfb2..6099edc 100644 --- a/js/settings.js +++ b/js/settings.js @@ -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) { diff --git a/js/storage.js b/js/storage.js index 39cf378..6d6ffe7 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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 = + ''; + } + } + }, +}; + 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) => { diff --git a/js/ui.js b/js/ui.js index f90fdaf..4ff016b 100644 --- a/js/ui.js +++ b/js/ui.js @@ -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'; } } 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" }, diff --git a/styles.css b/styles.css index c3f5fa0..6620248 100644 --- a/styles.css +++ b/styles.css @@ -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;