diff --git a/app/src/main/assets/app.js b/app/src/main/assets/app.js index 91cf7da..a0c62ba 100644 --- a/app/src/main/assets/app.js +++ b/app/src/main/assets/app.js @@ -369,7 +369,7 @@ function _md5(str) { ).join(''); } -// ── MD5 self-check (verifies implementation against RFC 1321 vector) ───────── +// ── MD5 self-test (verifies implementation against RFC 1321 vector) ───────── // MD5("abc") must equal "900150983cd24fb0d6963f7d28e17f72" let _md5SelfTestPassed = null; function _md5SelfTest() { @@ -378,7 +378,7 @@ function _md5SelfTest() { const actual = _md5('abc'); _md5SelfTestPassed = (actual === expected); if (!_md5SelfTestPassed) { - console.error('[Auth] MD5 self-check FAILED — got:', actual, ' expected:', expected); + console.error('[Auth] MD5 self-test FAILED — got:', actual, ' expected:', expected); } return _md5SelfTestPassed; } @@ -1044,7 +1044,6 @@ async function lfmCall(params) { if (!state.apiKey) throw new Error('No API key set. Go to Settings.'); const url = new URL(LASTFM_BASE); const p = { ...params, api_key: state.apiKey, format: 'json' }; - if (state.sessionKey && !p.sk) p.sk = state.sessionKey; Object.entries(p).forEach(([k, v]) => url.searchParams.set(k, v)); return _cachedFetch(url.toString()); } @@ -1081,7 +1080,7 @@ function _lfmFriendlyError(code, rawMsg) { * * Flow: * 1. Normalise key + secret (strip all non-hex chars, force lowercase) - * 2. Run MD5 self-check to catch any implementation regression + * 2. Run MD5 self-test to catch any implementation regression * 3. Build params (NO format / callback at this point) * 4. Compute api_sig = _lfmSig(params, secret) * 5. Add format=json AFTER signing @@ -1103,7 +1102,7 @@ async function lfmCallSigned(params) { if (keyNorm.length !== 32) console.warn('[Auth] API key is', keyNorm.length, 'chars — expected 32'); if (secNorm.length !== 32) console.warn('[Auth] API secret is', secNorm.length, 'chars — expected 32'); - // ── Step 2: MD5 self-check ───────────────────────────────────────────────── + // ── Step 2: MD5 self-test ───────────────────────────────────────────────── if (!_md5SelfTest()) { throw new Error('Internal MD5 error — authentication cannot proceed. Please report this bug.'); } @@ -2425,4 +2424,4 @@ function _lpFallbackCopy(text) { showToast('Copied', 'success'); } catch { showToast('Could not copy', 'error'); } } -function sanitizeFilename(name) { return name.replace(/[^a-z0-9\-_\s]/gi,'').replace(/\s+/g,'_').substring(0,60)||'playlist'; } +function sanitizeFilename(name) { return name.replace(/[^a-z0-9\-_\s]/gi,'').replace(/\s+/g,'_').substring(0,60)||'playlist'; } \ No newline at end of file diff --git a/app/src/main/assets/genres/genres.js b/app/src/main/assets/genres/genres.js index 6065f70..98ac456 100644 --- a/app/src/main/assets/genres/genres.js +++ b/app/src/main/assets/genres/genres.js @@ -38,6 +38,25 @@ const _GD_NO_ART = '2a96cbd8b46e442fc41c2b86b821562f'; // ── Screen init ─────────────────────────────────────────────── async function screen_genres() { + // Register hardware-back handler: + // 1st press closes the detail sheet if open; 2nd press lets nav.js pop the stack. + window._lwScreenBackHandlers['genres'] = function () { + // Close active dropdown first + if (_activeGDDropdown) { + _activeGDDropdown.classList.add('track-dropdown-leaving'); + setTimeout(() => { if (_activeGDDropdown) { _activeGDDropdown.remove(); _activeGDDropdown = null; } }, 160); + return true; + } + // Close genre detail overlay if open + const overlay = document.getElementById('genreDetailOverlay'); + if (overlay && overlay.classList.contains('open')) { + _closeGenreDetail(); + return true; + } + // Nothing intercepted — let nav.js handle the back (pop history) + return false; + }; + if (!state.username || !state.apiKey) { _genresShowError('Enter your username and API key in Settings first.', 'No credentials found'); return; @@ -714,6 +733,9 @@ function _openGDDropdown(btn, trackName, artistName) { if (typeof showToast === 'function') showToast('Cover art not available', 'error'); } }}, + { icon: 'delete', label: 'Delete Scrobble', fn: async () => { + await _lfmDeleteScrobble(trackName, artistName, null); + }}, ]; const menu = document.createElement('div'); @@ -1223,7 +1245,7 @@ function _genresShowError(sub, title) { // ── Tiny helpers ────────────────────────────────────────────── function _esc(str) { - return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function _escAttr(str) { return String(str || '').replace(/'/g,"\\'").replace(/"/g,'\\"'); diff --git a/app/src/main/assets/home/home.js b/app/src/main/assets/home/home.js index e24a02c..2851b57 100644 --- a/app/src/main/assets/home/home.js +++ b/app/src/main/assets/home/home.js @@ -1246,6 +1246,23 @@ function _openTrackDropdown(btn, trackName, artistName) { showToast('Cover art not available', 'error'); } }}, + { icon: 'delete', label: 'Delete Scrobble', action: () => { + if (!state.sessionKey) { showToast('Sign in to delete scrobbles', 'error'); return; } + showModal( + 'Delete Scrobble?', + `Remove \u201c${trackName}\u201d by ${artistName} from your Last.fm history?`, + async () => { + const ok = await _lfmDeleteScrobble(trackName, artistName, tsMs); + if (ok) { + const idx = _homeAllTracks.findIndex( + t => t.name === trackName && t.artist === artistName && + (!tsMs || t._timestamp === tsMs) + ); + if (idx !== -1) { _homeAllTracks.splice(idx, 1); _renderList(); } + } + } + ); + }}, ]; const menu = document.createElement('div'); diff --git a/app/src/main/assets/nav.js b/app/src/main/assets/nav.js index 8ba735d..a70923b 100644 --- a/app/src/main/assets/nav.js +++ b/app/src/main/assets/nav.js @@ -129,8 +129,16 @@ async function navigateTo(page, opts) { // 3. Record history — skipped for back-navigation so we don't re-push // the page we just popped. Also skip on the very first load (no currentPage). + // Root tabs (home / generator / playlist) don't push other root tabs onto + // the back stack — switching between them always exits on back press. + const _ROOT_TABS = new Set(['home', 'generator', 'playlist']); if (state.currentPage && !(opts && opts.isBack)) { - _navHistory.push(state.currentPage); + if (_ROOT_TABS.has(page) && _ROOT_TABS.has(state.currentPage)) { + // Tab → tab transition: clear the stack so back exits the app + _navHistory.length = 0; + } else { + _navHistory.push(state.currentPage); + } } // 3. Hide current screen diff --git a/app/src/main/assets/playlist/playlist.js b/app/src/main/assets/playlist/playlist.js index 69ef885..92797dc 100644 --- a/app/src/main/assets/playlist/playlist.js +++ b/app/src/main/assets/playlist/playlist.js @@ -449,6 +449,24 @@ function _plOpenTrackMenu(btn, trackName, artistName, plId) { } } }, + { + icon: 'delete', label: 'Delete Scrobble', + fn: () => { + if (!state.sessionKey) { showToast('Sign in to delete scrobbles', 'error'); return; } + showModal( + 'Delete Scrobble?', + `Remove \u201c${trackName}\u201d by ${artistName} from your Last.fm history?`, + async () => { + const ok = await _lfmDeleteScrobble(trackName, artistName, null); + if (ok) { + document.querySelectorAll( + `.pl-track-row[data-lp-name="${CSS.escape(trackName)}"][data-lp-artist="${CSS.escape(artistName)}"]` + ).forEach(row => row.remove()); + } + } + ); + } + }, ]; const menu = document.createElement('div'); diff --git a/app/src/main/assets/search/search.js b/app/src/main/assets/search/search.js index 52a0333..d3464de 100644 --- a/app/src/main/assets/search/search.js +++ b/app/src/main/assets/search/search.js @@ -597,6 +597,22 @@ function _showSearchDropdown(item, anchorBtn) { }); } + // "Delete Scrobble" — tracks only + if (isTrack) { + menuItems.push({ + icon: 'delete', + label: 'Delete Scrobble', + fn() { + if (!state.sessionKey) { showToast('Sign in to delete scrobbles', 'error'); return; } + showModal( + 'Delete Scrobble?', + `Remove \u201c${item._track}\u201d by ${item._artist} from your Last.fm history?`, + async () => { await _lfmDeleteScrobble(item._track, item._artist, null); } + ); + } + }); + } + // ── Build DOM ───────────────────────────────────────────── const menuEl = document.createElement('div'); menuEl.className = 'track-dropdown-menu'; @@ -746,8 +762,8 @@ function _showState(s) { function _esc(str) { if (!str) return ''; return String(str) - .replace(/&/g, '&') - .replace(//g, '>') + .replace(/&/g, '&') + .replace(//g, '>') .replace(/"/g, '"'); } \ No newline at end of file diff --git a/app/src/main/assets/settings/settings.html b/app/src/main/assets/settings/settings.html index 04f0cc6..cb73f6e 100644 --- a/app/src/main/assets/settings/settings.html +++ b/app/src/main/assets/settings/settings.html @@ -101,6 +101,17 @@ + +