import { bootstrapPluginsAndUi, checkPluginUpdates, loadPlugins, updatePlugin, } from './js/plugin-loader.js'; import { _autoMatchViz, _maybeShowNotationViewHint, _populateVizPicker, setViz, } from './js/viz.js'; import { exportDiagnostics, previewDiagnostics, } from './js/diagnostics-export.js'; import { _confirmDialog, _escAttr, _isElementVisible, _trapFocusInModal, esc, uiPrompt, } from './js/dom.js'; import { hwcInitSettingsUI, initHighwayColors, } from './js/highway-colors.js'; import { displayTuningName, displayTuningTargetDetails, displayTuningTargets, effectiveStringCount, isBassArrangement, parseRawTuningOffsets, songTuningContext, } from './js/tuning-display.js'; import { exportSettings, importSettings, } from './js/settings-io.js'; import { audio } from './js/audio-el.js'; import { S } from './js/player-state.js'; // Side-effect import: the three JUCE shims are IIFEs that install themselves and // publish through window.*. _resetJuceAudioShimChain is the one binding app.js needs. import { _resetJuceAudioShimChain } from './js/juce-audio.js'; import { _clearResumeSession, _hideResumePill, _maybeShowResumePill, _readResumeSession, _snapshotResumeSession, resumeLastSession, } from './js/resume-session.js'; import { _applyMastery, _applyMasteryAvailability, _autoplayExitEnabled, _countdownBeforeSongEnabled, _curPlaybackSpeed, _exitConfirmEnabled, _resetPlaybackSpeedForNewSong, _showUpNextEnabled, _wireSpeedPresetsOnce, applySpeedPreset, setMastery, setSpeed, } from './js/player-controls.js'; import { _cancelCountIn, armCreditsHideOnPlay, hideCountOverlay, hideSongCreditsOverlay, holdCreditsThen, isCountingIn, playClick, scheduleCreditsHide, showCountOverlay, showSongCreditsOverlay, startCountIn, startSongCountIn, } from './js/count-in.js'; import { _loopMutationGen, clearLoop, deleteSelectedLoop, loadSavedLoop, loadSavedLoops, loopA, loopB, saveCurrentLoop, setLoop, setLoopEnd, setLoopStart, updateLoopUI, } from './js/loops.js'; import { _buildSectionParents, _ensureSectionPracticeBar, _hideSectionPracticeBar, _installSectionPracticeDrawHook, _maybeRefreshSectionPracticeDuration, _placeSectionPracticeControlForChrome, _resetSectionPracticeLog, _scheduleSectionPracticeRetries, _sectionPracticeBarContains, _sectionPracticeBarIsReady, _sectionPracticePopoverOpen, _sectionPracticeSourceSections, _sectionPracticeStartTime, _setSectionPracticeMode, _syncSectionPracticeFromLoop, _updateSectionPracticeHighlight, invalidateParentCount, onPhraseNext, onPhrasePrev, onSectionParentClick, onSectionPracticeModeChange, onSectionPracticeWholeChange, practiceSection, renderSectionPracticeBar, resetSelection, toggleSectionPracticePopover, } from './js/section-practice.js'; import { configureHost } from './js/host.js'; import { formatTime } from './js/format.js'; import { L } from './js/library-state.js'; import { _LIB_FORMAT_KEY, _LIB_FORMAT_VALUES, _LIB_SORT_KEY, _LIB_SORT_VALUES, _activeLibraryProviderId, _applyLibFiltersToParams, _bumpLibNavGeneration, _getArrangementNamingMode, _lastLibSelected, _libNavItems, _libScrollOnNextRender, _libraryLocalFilename, _libraryProviderApi, _librarySongArtUrl, _librarySongId, _librarySyncState, _moveSelectionInItems, _onHeaderClick, _onNamingModeChange, _pollScanAndRefresh, _providerSupports, _readPersistedChoice, _removeLibCardsForFilename, _renderLibFilterChips, _resetLibraryProviderViewState, _setLibSelection, _setLibrarySyncState, _toggleHeader, _updateLibFiltersBadge, checkScanAndLoad, clearLibFilters, editBtn, filterFavTreeLetter, filterFavorites, filterLibrary, filterTreeLetter, fullRescanLibrary, goFavPage, goFavTreePage, goTreePage, hideScanBanner, libView, loadFavorites, loadLibrary, loadLibraryProviders, loadTreeView, renderGridCards, renderTreeInto, rescanLibrary, setFavView, setLibView, setLibraryProvider, sortFavorites, sortLibrary, stopInfiniteScroll, toggleAllArtists, toggleAllFavoriteArtists, toggleFavorite, toggleLibFilters, } from './js/library.js'; import { _editModalShouldClose, deleteSongFromModal, openEditModal, saveEditModal, } from './js/edit-modal.js'; import { APP_UPDATE_CHANNELS, INSTRUMENT_PATHWAYS, _appUpdatesWired, _avOffsetMs, _avSaveDebounce, _currentArrangementName, _defaultArrangement, _normalizeInstrumentPathway, _persistAvOffset, _postSetting, _settingSaveChain, _syncDefaultArrangementSelect, handleSliderInput, loadSettings, nudgeAvOffsetMs, persistSetting, pinCurrentArrangementDefault, saveSettings, setAvOffsetMs, setInstrumentPathway, setupAppUpdates, syncDefaultArrangementPin, } from './js/settings.js'; import { AUTOPLAY_HOLD_BACKSTOP_MS, AUTO_EXIT_GRACE_MS, _BRIDGE_RECORD_MIN_MS, _acquireWakeLock, _autoExitGen, _autoExitHeld, _autoExitTimer, _autoplayBackstop, _autoplayGen, _autoplayHeld, _autoplayHoldToken, _autoplayStart, _bridgeRecordLast, _clearAutoExit, _clearAutoplayHold, _desktopAwakeGen, _desktopAwakeReq, _pendingAutostart, _playbackApi, _playerOriginScreen, _recordPlaybackBridge, _releaseAutoplay, _releaseWakeLock, _resolvePlayerOrigin, _resultsOverlayVisible, _screenWakeLock, _settingsOriginScreen, _syncDesktopBridge, _wakeLockPending, _wakeLockRetry, _wakeLockWanted, artAbortController, closeCurrentSong, currentFilename, playSong, showScreen, } from './js/session.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 { setPlayButtonState, jucePlayer, _audioTime, _audioDuration, _songEventPayload, _markPlaybackPaused, _markPlaybackResumed, _emitPlaybackStopped, _emitSongPositionChanged, _waitForSongReady, _resetAudioSeekState, _audioSeek, togglePlay, seekBy, audioSeekGen, } from './js/transport.js'; // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; // ── Global keyboard shortcuts ───────────────────────────────────────────── // // `/` focuses the active screen's search input (Library / Favorites); // `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail // convention. The listener bails when the user is already typing in // any text-accepting element so it can't intercept normal typing — // including inputs inside the filters drawer, plugin settings, or // modal dialogs. function _isTextInput(el) { if (!el) return false; const tag = el.tagName; if (tag === 'INPUT') { // Some types (button, checkbox, radio, range, ...) don't // accept text; only intercept the ones that do. const t = (el.type || 'text').toLowerCase(); return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); } if (tag === 'TEXTAREA') return true; if (tag === 'SELECT') return true; if (el.isContentEditable) return true; return false; } function _isShortcutHelpKey(e) { return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/')); } function _isShortcutHelpSuppressedTarget(el) { if (!el) return false; const tag = el.tagName; if (tag === 'INPUT') { const t = (el.type || 'text').toLowerCase(); return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); } if (tag === 'TEXTAREA') return true; if (el.isContentEditable) return true; if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true; return false; } function _activeSearchInput() { // Pick the search field for whichever screen is currently active. // No match (e.g. on the player or settings screen) means `/` does // nothing — the shortcut only fires where a search box exists. const active = document.querySelector('.screen.active'); if (!active) return null; if (active.id === 'home') return document.getElementById('lib-filter'); if (active.id === 'favorites') return document.getElementById('fav-filter'); return null; } // ── Library keyboard navigation ────────────────────────────────────────── // // Arrow keys move a single "selected" item among the visible cards // (grid view) or song rows (tree view). Enter plays the selected // song. The selected element gets: // - native keyboard focus via .focus() so :focus-visible draws the // accessible ring (announced by screen readers, follows scroll) // - a `.selected` class that persists when focus drifts elsewhere // so the user can glance back and still see their place. // // Grid columns are inferred from the live computed grid template at // the moment of navigation, so up/down works correctly across all // breakpoints (1 / 2 / 3 / 4 cols depending on viewport). function _gridColumns(container) { // Count columns by grouping the first row of children by their // top coordinate. Robust against any grid-template-columns syntax // (`repeat(...)`, `auto-fit`, named lines, etc.) where naively // splitting `getComputedStyle().gridTemplateColumns` on whitespace // would miscount because of spaces inside `repeat(...)` / // `minmax(...)`. Falls back to 1 when the container is empty // so callers' max(1, ...) clamps stay valid. if (!container) return 1; const children = Array.from(container.children).filter( c => c && c.offsetParent !== null ); if (!children.length) return 1; const firstTop = children[0].getBoundingClientRect().top; let cols = 0; for (const c of children) { // Allow ~1px slop for sub-pixel rounding so two children that // would visually align still group together. if (Math.abs(c.getBoundingClientRect().top - firstTop) < 1.5) cols++; else break; } return Math.max(1, cols); } function _isInsideInteractiveControl(el) { // Bail when the user is interacting with anything that has its // own keyboard semantics — form controls (checkbox / select / // button) consume arrow keys for their own behavior, and the // filters drawer is a focus trap of those. Without this guard the // library's arrow nav would steal arrow presses from a focused // tuning checkbox or sort dropdown. if (!el) return false; const tag = el.tagName; if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return true; if (el.isContentEditable) return true; if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal')) return true; return false; } function _isSpaceKey(e) { return e.key === ' ' || e.key === 'Spacebar'; } function _shortcutDispatchBlocked(e) { if (_isTextInput(e.target)) return true; // Space in Section Practice bar should pause/resume, not toggle checkboxes/buttons. if (_isSpaceKey(e) && _sectionPracticeBarContains(e.target)) return false; // While the Section Practice popover is open, Esc just closes it (handled by // the popover's own keydown listener) — suppress the player-scope // "back to library" Esc so the user doesn't get bounced out of the player. if (e.key === 'Escape' && _sectionPracticePopoverOpen()) return true; // Space on the player screen should always play/pause, even if focus is on a // sidebar nav link, player rail button, popover control, or any other // interactive element — the shortcut dispatcher calls preventDefault so the // focused element won't also activate. Two exceptions keep native Space: // text inputs (already exempted above), and focus inside a true modal // dialog (role="dialog" aria-modal="true", or a .feedBack-modal overlay) // layered over the player — a modal traps interaction, so Space must reach // its focused control (e.g. the Close button) rather than toggle playback // behind it. Non-modal player popovers/toasts (loop A/B, arrangement pin, // role="dialog" aria-modal="false") are not modals and stay covered. if (_isSpaceKey(e) && _getCurrentContext().isPlayer && !(e.target && e.target.closest && e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) { return false; } // Escape is the universal "back" action and must fire like Space above even // when a transport/rail control ${sectionsHtml} `; // Click outside the inner panel (i.e. on the backdrop) closes the // modal — matches the conventional dialog UX. modal.addEventListener('click', (ev) => { if (ev.target === modal || ev.target.closest('[data-shortcuts-close]')) { 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 }); } }); document.body.appendChild(modal); // Move focus into the dialog so background shortcuts (and arrow // nav) can't fire on the underlying library entry while the // overlay is open. Close button is the safe default — there's no // primary input to focus on a read-only cheat sheet. const closeBtn = modal.querySelector('[data-shortcuts-close]'); if (closeBtn) closeBtn.focus({ preventScroll: true }); // Trap Tab / Shift+Tab inside the modal so focus can't escape to // the library content underneath while the overlay is open. _trapFocusInModal(modal); } document.addEventListener('keydown', (e) => { // Modifier-key combos belong to the browser / OS shortcuts; never // intercept those. if (e.ctrlKey || e.metaKey || e.altKey) return; if (_handleLibArrowNav(e)) return; // `?` (Shift+/) opens the keyboard-shortcuts cheat sheet. Some // Linux/Electron stacks report Shift+/ as key='/' with code='Slash', // so check the help shape before treating plain '/' as search. if (_isShortcutHelpKey(e)) { if (_isShortcutHelpSuppressedTarget(e.target || document.activeElement)) return; e.preventDefault(); // Stop other keydown listeners on document (notably the shortcut // registry below) from also consuming this event — otherwise a // Linux/Electron Shift+Slash reported as key='/' opens help here and // then the registry's plain `/` library-search shortcut focuses // #lib-filter behind the modal. (Copilot review on #602.) e.stopImmediatePropagation(); _openShortcutsModal(); return; } if (e.key === '/') { if (_isTextInput(document.activeElement)) return; // Also bail when focus is inside the filter drawer, a dialog, or // any other interactive region — those contexts have their own // keyboard semantics and shouldn't be hijacked by the search // shortcut (e.g. a focused checkbox inside the filters drawer). if (_isInsideInteractiveControl(document.activeElement)) return; const search = _activeSearchInput(); if (!search) return; e.preventDefault(); // suppress the literal '/' the input would receive search.focus(); // Move caret to end without mutating .value — round-tripping // the value resets the browser's undo stack and can fire // unexpected input events on some engines. setSelectionRange // is the no-side-effects path. try { const len = search.value.length; search.setSelectionRange(len, len); } catch { // Some input types (search/email/tel) don't support // selection APIs in older browsers; the focus alone is // still useful, just no caret-end guarantee. } return; } // Single-letter shortcuts that act on the focused / selected // library entry — works on both grid cards and tree rows. Each // dispatches to a button class that the entry markup already // exposes, so plugins can keep owning the actual behavior: // f → .fav-btn (favorite heart toggle) // e → .edit-btn (edit metadata modal) // No-op when no entry is currently focused / selected, when the // entry doesn't expose the requested button, or when the button is disabled. // Bails on text input / drawer focus so single-letter typing in // inputs still works. const entryShortcut = { f: 'button.fav-btn', e: 'button.edit-btn' }[e.key.toLowerCase()]; if (entryShortcut) { if (_isInsideInteractiveControl(document.activeElement)) return; const ae = document.activeElement; const activeScreen = document.querySelector('.screen.active'); const isEntry = el => el && el.classList && (el.classList.contains('song-card') || el.classList.contains('song-row')); // Scope both candidates to the active screen so that a stale // _lastLibSelected from Library doesn't fire when the user is // on Favorites (or vice-versa), and so pressing f/e/c on a // hidden screen can't accidentally persist that filename into // the current screen's localStorage key. const inActiveScreen = el => activeScreen && activeScreen.contains(el); const target = (isEntry(ae) && inActiveScreen(ae)) ? ae : (isEntry(_lastLibSelected) && inActiveScreen(_lastLibSelected) ? _lastLibSelected : null); if (!target) return; const btn = target.querySelector(entryShortcut); if (!btn || btn.disabled) return; e.preventDefault(); // Sync the persistent selection to the acted-on entry so that // Esc-to-close-modal returns focus to the correct element and // the `.selected` highlight stays consistent with the action. _setLibSelection(target, { focus: false }); btn.click(); return; } if (e.key === 'Escape') { // Modal-first: close the topmost open modal (edit-metadata, // shortcuts cheat sheet, future modals) so Esc dismisses // from anywhere — including when keyboard focus is inside // a form field within the modal. Restores focus to the // element that opened the modal (tracked in modal._opener) // so arrow nav resumes without an extra Tab; falls back to // _lastLibSelected when the opener is no longer in the DOM. const modals = document.querySelectorAll('[role="dialog"][aria-modal="true"].feedBack-modal'); if (modals.length) { e.preventDefault(); e.stopImmediatePropagation(); const modal = modals[modals.length - 1]; 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 }); return; } // Esc while typing in either search box clears + blurs. Other Esc // semantics (drawer close, screen back) are handled elsewhere; we // only act when a search box is the focused element. const ae = document.activeElement; if (ae && (ae.id === 'lib-filter' || ae.id === 'fav-filter')) { if (ae.value) { ae.value = ''; ae.dispatchEvent(new Event('input', { bubbles: true })); } ae.blur(); } } }); // ── Library ────────────────────────────────────────────────────────────── const _LIB_PROVIDER_KEY = 'feedBack.libProvider'; // Bumped on filter/sort/view changes so in-flight page fetches can detect // they've been superseded and skip rendering stale results. // cached from /api/library/tuning-names // ── Folder Library: filter bridge ───────────────────────────────────────── // Serialises the active lib filter state as URL params so the plugin can pass // them to /api/plugins/folder_library/tree — the same pattern grid and tree // views use when sending filter params to their own backend endpoints. window.feedBackLibFilterParams = function() { var p = new URLSearchParams(); _applyLibFiltersToParams(p); return p.toString(); }; // ── Grid View (server-side pagination, infinite scroll) ──────────────── // ── Tree View (server-side) ───────────────────────────────────────────── window.displayTuningName = displayTuningName; window.feedBack = window.feedBack || {}; window.slopsmith = window.feedBack; window.feedBack.displayTuningName = displayTuningName; window.feedBack.isBassArrangement = isBassArrangement; window.feedBack.effectiveStringCount = effectiveStringCount; window.feedBack.songTuningContext = songTuningContext; window.displayTuningTargets = displayTuningTargets; window.displayTuningTargetDetails = displayTuningTargetDetails; window.parseRawTuningOffsets = parseRawTuningOffsets; window.feedBack.displayTuningTargets = displayTuningTargets; window.feedBack.displayTuningTargetDetails = displayTuningTargetDetails; window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets; // ── App Updates (desktop-only) ─────────────────────────────────────────── // Velopack auto-update controls, rendered as the first block of the Settings // page. Whole block stays hidden in the plain web app; unhide + wire only // when the feedBack-desktop bridge (window.feedBackDesktop.update) is // present. On Linux the block renders but its controls are disabled — the // desktop reports platform === 'linux' and short-circuits the IPC. // ── Restart banner (desktop-only) ──────────────────────────────────────── // Subscribes to window.feedBackDesktop.update.onDownloaded and renders a // persistent banner with a "Restart now" button. Runs once at app boot so a // download finishing while the user is on a non-Settings screen still pops // the banner. function initAppUpdateBanner() { const updateApi = window.feedBackDesktop?.update; // Same capability gate as setupAppUpdates — the banner needs onDownloaded // to subscribe, getStatus to detect pre-existing pending updates on boot, // and apply to actually restart from the button. A bridge missing any // of these would partially fail; better to no-op cleanly. if (!updateApi || typeof updateApi.onDownloaded !== 'function' || typeof updateApi.getStatus !== 'function' || typeof updateApi.apply !== 'function') { return; } const BANNER_ID = 'feedBack-update-banner'; function renderUpdateBanner(payload) { // Avoid stacking duplicate banners if onDownloaded fires more than once. if (document.getElementById(BANNER_ID)) return; const banner = document.createElement('div'); banner.id = BANNER_ID; banner.setAttribute('role', 'status'); banner.style.cssText = [ 'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999', 'padding:10px 16px', 'background:linear-gradient(90deg,#1e3a8a,#4338ca)', 'color:#fff', 'font-size:13px', 'font-family:system-ui,sans-serif', 'display:flex', 'align-items:center', 'justify-content:space-between', 'gap:12px', 'box-shadow:0 2px 8px rgba(0,0,0,0.4)', ].join(';'); const text = document.createElement('span'); const version = payload && payload.version ? ` (${payload.version})` : ''; text.textContent = `Update downloaded${version} — restart to apply.`; const actions = document.createElement('span'); actions.style.cssText = 'display:flex;gap:8px;align-items:center'; const restartBtn = document.createElement('button'); restartBtn.textContent = 'Restart now'; restartBtn.style.cssText = [ 'padding:4px 12px', 'border-radius:4px', 'background:#fff', 'color:#1e3a8a', 'border:none', 'font-weight:600', 'cursor:pointer', 'font-size:13px', ].join(';'); restartBtn.addEventListener('click', async () => { restartBtn.disabled = true; restartBtn.textContent = 'Restarting…'; try { // apply() can resolve with { status: 'error' } instead of // throwing; only re-enable the button on that path. const result = await updateApi.apply(); if (result?.status === 'error') { console.warn('[updater] apply returned error:', result.message || 'unknown'); restartBtn.disabled = false; restartBtn.textContent = 'Restart now'; } } catch (e) { console.warn('[updater] apply failed:', e); restartBtn.disabled = false; restartBtn.textContent = 'Restart now'; } }); const dismissBtn = document.createElement('button'); dismissBtn.textContent = 'Later'; dismissBtn.setAttribute('aria-label', 'Dismiss update banner'); dismissBtn.style.cssText = [ 'padding:4px 10px', 'border-radius:4px', 'background:transparent', 'color:#fff', 'border:1px solid rgba(255,255,255,0.3)', 'cursor:pointer', 'font-size:13px', ].join(';'); dismissBtn.addEventListener('click', () => banner.remove()); actions.appendChild(restartBtn); actions.appendChild(dismissBtn); banner.appendChild(text); banner.appendChild(actions); const insert = () => { if (document.body) document.body.appendChild(banner); else document.addEventListener('DOMContentLoaded', () => document.body.appendChild(banner), { once: true }); }; insert(); } try { updateApi.onDownloaded((payload) => { try { renderUpdateBanner(payload); } catch (e) { console.warn('[updater] renderUpdateBanner failed:', e); } }); } catch (e) { console.warn('[updater] onDownloaded subscribe failed:', e); } // Catch pre-existing pending updates (downloaded in a previous session, // or restored on launch). onDownloaded only fires for downloads that // complete in the current session, so do an explicit status check too. try { void Promise.resolve(updateApi.getStatus()).then((status) => { // Render the banner for any 'downloaded' status; the version // string is best-effort — renderUpdateBanner() already drops the // "(vX.Y.Z)" suffix when none is supplied, so an update reported // without pending.version still surfaces the restart prompt. if (status && status.status === 'downloaded') { renderUpdateBanner({ version: status.pending?.version, channel: status.channel }); } }).catch((e) => { console.warn('[updater] getStatus on init failed:', e); }); } catch (e) { console.warn('[updater] getStatus on init threw:', e); } } // Open a native OS folder picker via the Electron bridge (desktop only) and // stash the chosen path into the DLC input. User still has to hit Save. async function pickDlcFolder() { if (!window.feedBackDesktop?.pickDirectory) return; const path = await window.feedBackDesktop.pickDirectory(); if (path) document.getElementById('dlc-path').value = path; } document.getElementById('arr-select')?.addEventListener('change', syncDefaultArrangementPin); async function uploadSongs(fileList) { if (!fileList || fileList.length === 0) return; const all = Array.from(fileList); // Optional UI element — only present when on the Settings screen. // The navbar entry triggers uploads from any screen, where these aren't. const status = document.getElementById('rescan-status'); const setStatus = (s) => { if (status) status.textContent = s; }; // Client-side extension filter so we don't waste a round-trip on // clearly-invalid picks. The server validates again. const failures = []; const files = []; for (const f of all) { const lower = f.name.toLowerCase(); if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) { files.push(f); } else { failures.push(`${f.name}: only .feedpak or .sloppak accepted`); } } if (files.length === 0) { if (failures.length) alert(failures.join('\n')); return; } // The backend caps batches at _MAX_UPLOAD_FILES (50). Chunk if needed so a // big drag-and-drop of an album folder still works end-to-end. const BATCH = 50; const chunks = []; for (let i = 0; i < files.length; i += BATCH) chunks.push(files.slice(i, i + BATCH)); let uploaded = 0; const postChunk = async (chunk, overwrite) => { const form = new FormData(); for (const f of chunk) form.append('file', f); const url = '/api/songs/upload' + (overwrite ? '?overwrite=1' : ''); const resp = await fetch(url, { method: 'POST', body: form }); if (!resp.ok) { let data = {}; try { data = await resp.json(); } catch (_) {} // Whole-request rejection (DLC misconfig, payload too large, etc.). throw new Error(data.error || resp.statusText || `HTTP ${resp.status}`); } const body = await resp.json(); return body.results || []; }; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const label = chunks.length > 1 ? `Uploading batch ${i + 1}/${chunks.length} (${chunk.length} files)...` : `Uploading ${chunk.length} file${chunk.length === 1 ? '' : 's'}...`; setStatus(label); let results; try { results = await postChunk(chunk, false); } catch (e) { for (const f of chunk) failures.push(`${f.name}: ${e.message}`); continue; } // Index file objects by name so a follow-up overwrite request can // resend the same blobs. Names within a chunk are unique on disk // (DLC dir is flat for this purpose), but two distinct user picks // could share a name — Map.set keeps the last one, which matches // server-side last-write-wins semantics. const byName = new Map(chunk.map(f => [f.name, f])); const conflicts = []; for (const r of results) { if (r.status === 'ok') { uploaded++; } else if (r.status === 'exists') { conflicts.push(r); } else { failures.push(`${r.filename}: ${r.error || 'upload failed'}`); } } if (conflicts.length > 0) { const names = conflicts.map(c => c.filename); const preview = names.slice(0, 5).join(', ') + (names.length > 5 ? `, +${names.length - 5} more` : ''); const ok = confirm( `${conflicts.length} file${conflicts.length === 1 ? '' : 's'} already exist in your DLC folder:\n${preview}\n\nOverwrite?` ); if (!ok) { for (const c of conflicts) failures.push(`${c.filename}: skipped (already exists)`); continue; } const retryFiles = conflicts .map(c => byName.get(c.filename)) .filter(Boolean); setStatus(`Overwriting ${retryFiles.length} file${retryFiles.length === 1 ? '' : 's'}...`); let retryResults; try { retryResults = await postChunk(retryFiles, true); } catch (e) { for (const f of retryFiles) failures.push(`${f.name}: ${e.message}`); continue; } for (const r of retryResults) { if (r.status === 'ok') uploaded++; else failures.push(`${r.filename}: ${r.error || 'upload failed'}`); } } } if (failures.length === 0) { setStatus(`Uploaded ${uploaded} file${uploaded === 1 ? '' : 's'}. Scanning...`); } else { // Denominator is the full user selection (`all.length`), not just the // post-filter `files.length`. Otherwise picking one valid file plus // one `.txt` would show "Uploaded 1/1" with a failure listed below, // overstating the success rate. const total = all.length; const msg = `Uploaded ${uploaded}/${total}. ${failures.length} failed:\n` + failures.join('\n'); alert(msg); setStatus(`Uploaded ${uploaded}/${total}, ${failures.length} failed.`); } if (uploaded > 0) { // Server kicked off a background scan after the batch finished; poll // for completion and refresh the library when it finishes. _pollScanAndRefresh(status); } } // ── Plugin functions loaded dynamically from plugin screen.js files ────── // (searchCF, installCF, loginCF, searchUG, buildFromUG, etc.) // ── Retune ─────────────────────────────────────────────────────────────── function retuneSong(filename, title, tuning, target) { target = target || 'E Standard'; if (!confirm(`Convert "${title}" from ${tuning} to ${target}?`)) return; // Show modal overlay const modal = document.createElement('div'); modal.id = 'retune-modal'; modal.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm'; modal.innerHTML = `

Converting to ${target}

${title}

Connecting...

`; document.body.appendChild(modal); const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/retune?filename=${encodeURIComponent(decodeURIComponent(filename))}&target=${encodeURIComponent(target)}`); ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.progress !== undefined) { document.getElementById('retune-bar').style.width = msg.progress + '%'; } if (msg.stage) { document.getElementById('retune-stage').textContent = msg.stage; } if (msg.done) { modal.querySelector('.bg-dark-700').innerHTML = `

Done!

${msg.filename}

`; } if (msg.error) { modal.querySelector('.bg-dark-700').innerHTML = `

Failed

${msg.error}

`; } }; ws.onerror = () => { modal.querySelector('.bg-dark-700').innerHTML = `

Connection lost

`; }; } function _applyPreservePitch(el) { if (!el) return; if ('preservesPitch' in el) el.preservesPitch = true; if ('mozPreservesPitch' in el) el.mozPreservesPitch = true; if ('webkitPreservesPitch' in el) el.webkitPreservesPitch = true; } _applyPreservePitch(audio); // In FeedBack Desktop, WASAPI Exclusive Mode locks the audio device so Chromium // cannot play through it. When window._juceMode is true, song audio is routed // through the JUCE backing track player instead of the HTML5