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, setupWindowOptions, 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'; import { ShortcutPanel, _DEBUG_SHORTCUTS, _activePanel, _activeSearchInput, _defaultPanel, _getCurrentContext, _gridColumns, _handleLibArrowNav, _isInsideInteractiveControl, _isShortcutActive, _isShortcutHelpKey, _isShortcutHelpSuppressedTarget, _isSpaceKey, _isTextInput, _modifiersMatch, _openShortcutsModal, _panels, _shortcutDispatchBlocked, defaultPanel, } from './js/shortcuts.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; // ── 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). // ── 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