diff --git a/static/app.js b/static/app.js
index 9350924..5b9f6e7 100644
--- a/static/app.js
+++ b/static/app.js
@@ -190,6 +190,12 @@ import {
toggleFavorite,
toggleLibFilters,
} from './js/library.js';
+import {
+ _editModalShouldClose,
+ deleteSongFromModal,
+ openEditModal,
+ saveEditModal,
+} from './js/edit-modal.js';
// The playback transport. These used to BE app.js — they are imported back now, and the
// four modules that reached for them through the host seam import them directly instead.
import {
@@ -3879,148 +3885,6 @@ registerShortcut({
handler: () => _adjustSongVolume(-1)
});
-// ── Edit metadata modal ─────────────────────────────────────────────────
-function openEditModal(songData, openerEl) {
- const artUrl = `/api/song/${encodeURIComponent(songData.f)}/art?t=${Date.now()}`;
- const modal = document.createElement('div');
- modal.id = 'edit-modal';
- modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
- // role=dialog: assistive tech announces it as a modal; also lets
- // the global keyboard listener's `_isInsideInteractiveControl`
- // bail when typing inside the modal so Library shortcuts don't
- // hijack keys from the edit form.
- modal.setAttribute('role', 'dialog');
- modal.setAttribute('aria-modal', 'true');
- modal.setAttribute('aria-label', 'Edit song metadata');
- // Record the element that triggered the modal so Esc / Cancel can
- // return focus to the exact entry the user was on, even if
- // _lastLibSelected changes before the modal closes.
- // Prefer the explicitly-passed openerEl (from the edit-btn click
- // handler, which has the exact [data-play] parent) over
- // _lastLibSelected, which may not have been updated when the
- // click's stopPropagation() prevented the card-click handler.
- const _emActive = document.querySelector('.screen.active');
- const _emLast = (_lastLibSelected && document.body.contains(_lastLibSelected)
- && _emActive && _emActive.contains(_lastLibSelected)) ? _lastLibSelected : null;
- modal._opener = (openerEl && document.body.contains(openerEl)) ? openerEl : _emLast;
- modal.innerHTML = `
-
-
Edit Song
-
-
-
-
-
- Change
-
-
-
-
Click image to change album art
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
`;
- document.body.appendChild(modal);
-
- // Move focus into the dialog's first text input so background
- // shortcuts (and arrow nav) can't fire on the underlying library
- // entry while the edit form is open. Title is the natural primary
- // field — most edits are correcting spelling there. Caret-end
- // selection so the user can keep typing rather than overtype the
- // current value.
- const titleInput = document.getElementById('edit-title');
- if (titleInput) {
- titleInput.focus({ preventScroll: true });
- try {
- const len = titleInput.value.length;
- titleInput.setSelectionRange(len, len);
- } catch { /* some browsers reject selection on certain input types */ }
- }
-
- // Trap Tab / Shift+Tab inside the modal so focus can't escape to
- // the library content underneath while the edit form is open.
- _trapFocusInModal(modal);
-
- // Click on art triggers file input
- document.getElementById('edit-art-wrapper').addEventListener('click', () => {
- document.getElementById('edit-art-file').click();
- });
-
- // Save — wired in JS (not an inline onclick) so the filename never has to
- // survive embedding in a single-quoted attribute string. encodeURIComponent
- // does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break
- // the inline `saveEditModal('…')` handler and silently fail the save. The
- // raw filename lives in the closure; encode it here for saveEditModal.
- const saveBtn = modal.querySelector('[data-edit-save]');
- if (saveBtn) {
- saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f)));
- }
-
- const deleteBtn = modal.querySelector('[data-delete-filename]');
- if (deleteBtn) {
- deleteBtn.addEventListener('click', () => {
- deleteSongFromModal(deleteBtn.dataset.deleteFilename);
- });
- }
-
- // Close on backdrop click or Cancel button; restore focus to opener.
- // Backdrop dismissal requires the gesture's mousedown to have STARTED on
- // the backdrop — not just the click/mouseup to land there. Otherwise a
- // click-drag that begins inside a field (e.g. selecting text) and is
- // released past the modal edge resolves its `click` target to the backdrop
- // and silently discards the edit. Cancel / ✕ (data-edit-close) always close.
- let _downOnBackdrop = false;
- modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); });
- modal.addEventListener('click', (e) => {
- if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return;
- const opener = modal._opener;
- modal.remove();
- const focusTarget = (opener && document.body.contains(opener)) ? opener
- : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
- if (focusTarget) focusTarget.focus({ preventScroll: true });
- });
-}
-
-// Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕
-// control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH
-// the click target to be the backdrop element itself AND the gesture to have
-// started there (downOnBackdrop) — so a click-drag begun inside a field and
-// released on the backdrop does not discard the form. Pure + top-level so it's
-// unit-testable in isolation.
-function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
- if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
- return clickTarget === modalEl && downOnBackdrop === true;
-}
-
function previewEditArt(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
@@ -4030,104 +3894,6 @@ function previewEditArt(input) {
reader.readAsDataURL(input.files[0]);
}
-async function saveEditModal(encodedFilename) {
- const filename = decodeURIComponent(encodedFilename);
-
- // Save metadata
- await fetch(`/api/song/${encodeURIComponent(filename)}/meta`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- title: document.getElementById('edit-title').value.trim(),
- artist: document.getElementById('edit-artist').value.trim(),
- album: document.getElementById('edit-album').value.trim(),
- // Year is normalised server-side (non-numeric/empty → ""), so a
- // blank or cleared field round-trips safely.
- year: document.getElementById('edit-year').value.trim(),
- }),
- });
-
- // Upload art if changed
- const fileInput = document.getElementById('edit-art-file');
- if (fileInput.files && fileInput.files[0]) {
- const reader = new FileReader();
- reader.onload = async (e) => {
- await fetch(`/api/song/${encodeURIComponent(filename)}/art/upload`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ image: e.target.result }),
- });
- };
- reader.readAsDataURL(fileInput.files[0]);
- }
-
- const modal = document.getElementById('edit-modal');
- const opener = modal ? modal._opener : null;
- if (modal) modal.remove();
- // Restore focus to the entry the modal was opened from so subsequent
- // keyboard navigation resumes correctly (same as Esc / Cancel paths).
- const focusTarget = (opener && document.body.contains(opener)) ? opener
- : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
- if (focusTarget) focusTarget.focus({ preventScroll: true });
- // Refresh current view
- const activeScreen = document.querySelector('.screen.active');
- if (activeScreen?.id === 'favorites') loadFavorites();
- else loadLibrary();
-}
-
-async function deleteSongFromModal(filename) {
- const title = (document.getElementById('edit-title')?.value || filename).trim();
- const ok = await _confirmDialog({
- title: 'Remove from library?',
- body: `
Remove ${_escAttr(title)} from your library?
-
This permanently deletes the file from disk. This cannot be undone.
`,
- confirmText: 'Remove',
- cancelText: 'Cancel',
- danger: true,
- });
- if (!ok) return;
- let resp;
- try {
- resp = await fetch(`/api/song/${encodeURIComponent(filename)}`, { method: 'DELETE' });
- } catch (e) {
- alert(`Delete failed: ${e.message}`);
- return;
- }
- if (!resp.ok) {
- let msg = resp.statusText;
- try { msg = (await resp.json()).error || msg; } catch (_) {}
- alert(`Delete failed: ${msg}`);
- return;
- }
- const modal = document.getElementById('edit-modal');
- if (modal) modal.remove();
- L.treeStats = null;
- L.favTreeStats = null;
- L.tuningNames = null;
-
- // Remove the deleted song's card from any currently-rendered grid/tree
- // so the user sees it disappear without waiting for a refetch. A full
- // loadLibrary() here would re-call loadGridPage(currentPage), which
- // uses 'append' mode when currentPage > 0 and re-appends the same
- // (now-shortened) page on top of what's already rendered — leaving
- // the deleted card visible. Direct DOM removal also preserves scroll
- // position, which a refetch from page 0 would lose.
- _removeLibCardsForFilename(filename);
-
- // Tree views group by artist with song counts; a single card removal
- // leaves stale counts, so refresh the tree for whichever screen we're
- // looking at (each tree-view renderer replaces innerHTML cleanly).
- const activeScreen = document.querySelector('.screen.active');
- if (activeScreen?.id === 'favorites') {
- // loadFavorites() routes to either loadFavGridPage (always
- // 'replace') or loadFavTreeView — both safe for a single delete.
- loadFavorites();
- } else if (libView === 'tree') {
- loadTreeView();
- }
- // Main library grid view: DOM removal above is sufficient.
-}
-
async function syncLibrarySong(providerId, songId, options = {}) {
const opts = options && typeof options === 'object' ? options : {};
const { playWhenReady = false } = opts;
diff --git a/static/js/edit-modal.js b/static/js/edit-modal.js
new file mode 100644
index 0000000..fc8197c
--- /dev/null
+++ b/static/js/edit-modal.js
@@ -0,0 +1,258 @@
+// The library's edit-song modal: open, validate, save, delete.
+//
+// Interface width ZERO — nothing in app.js calls into this cluster; app.js only needs the four
+// names on the window contract so the markup's onclick= handlers resolve. That is what makes it
+// the cleanest slice left, and it only became clean because the LIBRARY came out first (#896):
+// every dependency this modal has is now a module.
+//
+// It reads six bindings out of ./library.js (loadLibrary, loadFavorites, loadTreeView,
+// _removeLibCardsForFilename, libView, _lastLibSelected) and never writes one — checked, which
+// matters: an imported binding is READ-ONLY, so a single write would have forced a setter or a
+// container. Every use is a read, so plain imports suffice.
+//
+// Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back.
+import { _confirmDialog, _escAttr, _trapFocusInModal } from './dom.js';
+import { L } from './library-state.js';
+import {
+ _lastLibSelected, _removeLibCardsForFilename, libView, loadFavorites, loadLibrary, loadTreeView,
+} from './library.js';
+
+// ── Edit metadata modal ─────────────────────────────────────────────────
+export function openEditModal(songData, openerEl) {
+ const artUrl = `/api/song/${encodeURIComponent(songData.f)}/art?t=${Date.now()}`;
+ const modal = document.createElement('div');
+ modal.id = 'edit-modal';
+ modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
+ // role=dialog: assistive tech announces it as a modal; also lets
+ // the global keyboard listener's `_isInsideInteractiveControl`
+ // bail when typing inside the modal so Library shortcuts don't
+ // hijack keys from the edit form.
+ modal.setAttribute('role', 'dialog');
+ modal.setAttribute('aria-modal', 'true');
+ modal.setAttribute('aria-label', 'Edit song metadata');
+ // Record the element that triggered the modal so Esc / Cancel can
+ // return focus to the exact entry the user was on, even if
+ // _lastLibSelected changes before the modal closes.
+ // Prefer the explicitly-passed openerEl (from the edit-btn click
+ // handler, which has the exact [data-play] parent) over
+ // _lastLibSelected, which may not have been updated when the
+ // click's stopPropagation() prevented the card-click handler.
+ const _emActive = document.querySelector('.screen.active');
+ const _emLast = (_lastLibSelected && document.body.contains(_lastLibSelected)
+ && _emActive && _emActive.contains(_lastLibSelected)) ? _lastLibSelected : null;
+ modal._opener = (openerEl && document.body.contains(openerEl)) ? openerEl : _emLast;
+ modal.innerHTML = `
+
+
Edit Song
+
+
+
+
+
+ Change
+
+
+
+
Click image to change album art
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ document.body.appendChild(modal);
+
+ // Move focus into the dialog's first text input so background
+ // shortcuts (and arrow nav) can't fire on the underlying library
+ // entry while the edit form is open. Title is the natural primary
+ // field — most edits are correcting spelling there. Caret-end
+ // selection so the user can keep typing rather than overtype the
+ // current value.
+ const titleInput = document.getElementById('edit-title');
+ if (titleInput) {
+ titleInput.focus({ preventScroll: true });
+ try {
+ const len = titleInput.value.length;
+ titleInput.setSelectionRange(len, len);
+ } catch { /* some browsers reject selection on certain input types */ }
+ }
+
+ // Trap Tab / Shift+Tab inside the modal so focus can't escape to
+ // the library content underneath while the edit form is open.
+ _trapFocusInModal(modal);
+
+ // Click on art triggers file input
+ document.getElementById('edit-art-wrapper').addEventListener('click', () => {
+ document.getElementById('edit-art-file').click();
+ });
+
+ // Save — wired in JS (not an inline onclick) so the filename never has to
+ // survive embedding in a single-quoted attribute string. encodeURIComponent
+ // does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break
+ // the inline `saveEditModal('…')` handler and silently fail the save. The
+ // raw filename lives in the closure; encode it here for saveEditModal.
+ const saveBtn = modal.querySelector('[data-edit-save]');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f)));
+ }
+
+ const deleteBtn = modal.querySelector('[data-delete-filename]');
+ if (deleteBtn) {
+ deleteBtn.addEventListener('click', () => {
+ deleteSongFromModal(deleteBtn.dataset.deleteFilename);
+ });
+ }
+
+ // Close on backdrop click or Cancel button; restore focus to opener.
+ // Backdrop dismissal requires the gesture's mousedown to have STARTED on
+ // the backdrop — not just the click/mouseup to land there. Otherwise a
+ // click-drag that begins inside a field (e.g. selecting text) and is
+ // released past the modal edge resolves its `click` target to the backdrop
+ // and silently discards the edit. Cancel / ✕ (data-edit-close) always close.
+ let _downOnBackdrop = false;
+ modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); });
+ modal.addEventListener('click', (e) => {
+ if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return;
+ const opener = modal._opener;
+ modal.remove();
+ const focusTarget = (opener && document.body.contains(opener)) ? opener
+ : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
+ if (focusTarget) focusTarget.focus({ preventScroll: true });
+ });
+}
+
+// Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕
+// control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH
+// the click target to be the backdrop element itself AND the gesture to have
+// started there (downOnBackdrop) — so a click-drag begun inside a field and
+// released on the backdrop does not discard the form. Pure + top-level so it's
+// unit-testable in isolation.
+export function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
+ if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
+ return clickTarget === modalEl && downOnBackdrop === true;
+}
+
+export async function saveEditModal(encodedFilename) {
+ const filename = decodeURIComponent(encodedFilename);
+
+ // Save metadata
+ await fetch(`/api/song/${encodeURIComponent(filename)}/meta`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: document.getElementById('edit-title').value.trim(),
+ artist: document.getElementById('edit-artist').value.trim(),
+ album: document.getElementById('edit-album').value.trim(),
+ // Year is normalised server-side (non-numeric/empty → ""), so a
+ // blank or cleared field round-trips safely.
+ year: document.getElementById('edit-year').value.trim(),
+ }),
+ });
+
+ // Upload art if changed
+ const fileInput = document.getElementById('edit-art-file');
+ if (fileInput.files && fileInput.files[0]) {
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ await fetch(`/api/song/${encodeURIComponent(filename)}/art/upload`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: e.target.result }),
+ });
+ };
+ reader.readAsDataURL(fileInput.files[0]);
+ }
+
+ const modal = document.getElementById('edit-modal');
+ const opener = modal ? modal._opener : null;
+ if (modal) modal.remove();
+ // Restore focus to the entry the modal was opened from so subsequent
+ // keyboard navigation resumes correctly (same as Esc / Cancel paths).
+ const focusTarget = (opener && document.body.contains(opener)) ? opener
+ : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
+ if (focusTarget) focusTarget.focus({ preventScroll: true });
+ // Refresh current view
+ const activeScreen = document.querySelector('.screen.active');
+ if (activeScreen?.id === 'favorites') loadFavorites();
+ else loadLibrary();
+}
+
+export async function deleteSongFromModal(filename) {
+ const title = (document.getElementById('edit-title')?.value || filename).trim();
+ const ok = await _confirmDialog({
+ title: 'Remove from library?',
+ body: `
Remove ${_escAttr(title)} from your library?
+
This permanently deletes the file from disk. This cannot be undone.
`,
+ confirmText: 'Remove',
+ cancelText: 'Cancel',
+ danger: true,
+ });
+ if (!ok) return;
+ let resp;
+ try {
+ resp = await fetch(`/api/song/${encodeURIComponent(filename)}`, { method: 'DELETE' });
+ } catch (e) {
+ alert(`Delete failed: ${e.message}`);
+ return;
+ }
+ if (!resp.ok) {
+ let msg = resp.statusText;
+ try { msg = (await resp.json()).error || msg; } catch (_) {}
+ alert(`Delete failed: ${msg}`);
+ return;
+ }
+ const modal = document.getElementById('edit-modal');
+ if (modal) modal.remove();
+ L.treeStats = null;
+ L.favTreeStats = null;
+ L.tuningNames = null;
+
+ // Remove the deleted song's card from any currently-rendered grid/tree
+ // so the user sees it disappear without waiting for a refetch. A full
+ // loadLibrary() here would re-call loadGridPage(currentPage), which
+ // uses 'append' mode when currentPage > 0 and re-appends the same
+ // (now-shortened) page on top of what's already rendered — leaving
+ // the deleted card visible. Direct DOM removal also preserves scroll
+ // position, which a refetch from page 0 would lose.
+ _removeLibCardsForFilename(filename);
+
+ // Tree views group by artist with song counts; a single card removal
+ // leaves stale counts, so refresh the tree for whichever screen we're
+ // looking at (each tree-view renderer replaces innerHTML cleanly).
+ const activeScreen = document.querySelector('.screen.active');
+ if (activeScreen?.id === 'favorites') {
+ // loadFavorites() routes to either loadFavGridPage (always
+ // 'replace') or loadFavTreeView — both safe for a single delete.
+ loadFavorites();
+ } else if (libView === 'tree') {
+ loadTreeView();
+ }
+ // Main library grid view: DOM removal above is sufficient.
+}
diff --git a/tests/js/edit_metadata_modal.test.js b/tests/js/edit_metadata_modal.test.js
index 178013e..9d6dd26 100644
--- a/tests/js/edit_metadata_modal.test.js
+++ b/tests/js/edit_metadata_modal.test.js
@@ -1,4 +1,4 @@
-// Regression guards for two Edit-Metadata modal fixes (static/app.js):
+// Regression guards for two Edit-Metadata modal fixes (static/js/edit-modal.js):
//
// 1. Year is editable — the modal renders an `edit-year` field and
// saveEditModal() includes `year` in the POST /api/song//meta body.
@@ -19,8 +19,11 @@ const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
-const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
-const readApp = () => fs.readFileSync(APP_JS, 'utf8');
+// R3d: the edit modal was carved out of app.js into its own module. Bodies unchanged — only
+// the file moved. (It could go cleanly because the LIBRARY came out first: every dependency the
+// modal has is a module now, and it reads six library bindings without writing any.)
+const EDIT_MODAL_JS = path.join(__dirname, '..', '..', 'static', 'js', 'edit-modal.js');
+const readApp = () => fs.readFileSync(EDIT_MODAL_JS, 'utf8');
function loadFn(signature, sandbox, exportAs) {
const fnSrc = extractFunction(readApp(), signature);