diff --git a/static/capabilities/midi-input.js b/static/capabilities/midi-input.js index 10161b1..1bfb708 100644 --- a/static/capabilities/midi-input.js +++ b/static/capabilities/midi-input.js @@ -177,14 +177,31 @@ async function _discover() { if (providers.size === 0) return _unavailable('No MIDI provider registered', _snapshot()); let found = 0; + let enumerated = 0; + let lastError = null; for (const provider of providers.values()) { if (!provider.handlers.enumerate) continue; let list; try { list = await provider.handlers.enumerate(); } catch (e) { - // requestMIDIAccess rejection = permission denied / unsupported. - return _denied(_str(e && e.message, 'MIDI access denied'), _snapshot()); + // One provider failing (e.g. browser Web-MIDI permission denied, + // or unsupported) must NOT hide other providers' devices (e.g. a + // native/desktop MIDI provider). Record it and keep going; denial + // is only reported below when NO provider enumerated successfully. + lastError = e; + // Drop this provider's now-unverifiable sources so revoked / + // unplugged devices don't linger as selectable and fail on a + // later open (it just failed to enumerate, so we can't trust its + // previous list). + for (const [key, s] of Array.from(sources.entries())) { + if (s.providerId === provider.id) { + _closeSessionInternal(key, 'enumerate-failed'); + sources.delete(key); + } + } + continue; } + enumerated += 1; const fresh = new Set(); for (const raw of (Array.isArray(list) ? list : [])) { const sourceId = _str(raw.sourceId || raw.id, ''); @@ -216,6 +233,11 @@ } // Restore a previously-selected source if it reappeared. if (selectedKey && !sources.has(selectedKey)) { /* keep the preference; it may return later */ } + // Only surface a denial when NO provider could enumerate — otherwise a + // single denied/unsupported provider would mask the working ones. + if (enumerated === 0 && lastError) { + return _denied(_str(lastError && lastError.message, 'MIDI access denied'), _snapshot()); + } _emit('sources-changed', { count: found }); _contributeDiagnostics(); return _handled(_snapshot({ discovered: found })); diff --git a/static/v3/dashboard.js b/static/v3/dashboard.js index 9adce79..1fdc1a5 100644 --- a/static/v3/dashboard.js +++ b/static/v3/dashboard.js @@ -240,6 +240,10 @@ if (window.v3AudioRouting && typeof window.v3AudioRouting.render === 'function') { try { window.v3AudioRouting.render(document.getElementById('v3-audio-routing')); } catch (e) { /* */ } } + // Signal that #v3-home has been (re)built, so the first-run onboarding + // tour can wait for the real cards instead of attaching to nodes from a + // prior render that this innerHTML swap just replaced. + try { document.dispatchEvent(new CustomEvent('v3:dashboard-rendered')); } catch (e) { /* older runtimes */ } } function statCard(value, unit, unitColor) { diff --git a/static/v3/onboarding-tour.js b/static/v3/onboarding-tour.js index 20e2968..7e8f3db 100644 --- a/static/v3/onboarding-tour.js +++ b/static/v3/onboarding-tour.js @@ -93,18 +93,47 @@ try { if (t.hasSeen(TOUR_ID) || t.hasDismissed(TOUR_ID)) return; } catch (e) { /* private mode — fall through and attempt once */ } - // Make sure the home screen is in view so the spotlight targets exist; - // the per-step waitFor handles the async dashboard render. + // Make sure the home screen is in view so the spotlight targets exist. if (typeof window.showScreen === 'function') { try { window.showScreen('v3-home'); } catch (e) { /* best-effort */ } } - // Defer a frame so the 'v3:profile-updated' dashboard re-render has a - // chance to begin before Shepherd starts polling for the first target. - var raf = window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); }; - raf(function () { try { t.start(TOUR_ID); } catch (e) { /* degrade */ } }); + // Wait for the dashboard to finish (re)rendering #v3-home before + // attaching Shepherd. Starting after a single animation frame can latch + // onto the pre-render #v3-hero / [data-tour] nodes that the async render + // then replaces, breaking the tour. Listen for the render-complete + // event; fall back on a timeout in case the render already finished + // (no event coming) or never fires. + var started = false; + var go = function () { + if (started) return; + started = true; + try { document.removeEventListener('v3:dashboard-rendered', go); } catch (e) { /* */ } + try { t.start(TOUR_ID); } catch (e) { /* degrade */ } + }; + try { document.addEventListener('v3:dashboard-rendered', go, { once: true }); } catch (e) { /* */ } + setTimeout(go, 1200); } - window.v3OnboardingTour = { startFirstRun: startFirstRun }; + // Onboarding paths that navigate straight to the player ("Play it now") + // can't start the tour immediately — there is no home screen to spotlight + // yet. Arm it to run the first time the user lands back on v3-home. + function armPendingFirstRun() { + var t = window.slopsmithTour; + if (!t || typeof t.start !== 'function') return; + try { + if (t.hasSeen(TOUR_ID) || t.hasDismissed(TOUR_ID)) return; + } catch (e) { /* private mode — fall through */ } + var sm = window.slopsmith; + if (!sm || typeof sm.on !== 'function') return; + var onScreen = function (e) { + if (!(e && e.detail && e.detail.id === 'v3-home')) return; + try { if (typeof sm.off === 'function') sm.off('screen:changed', onScreen); } catch (_) { /* */ } + startFirstRun(); + }; + sm.on('screen:changed', onScreen); + } + + window.v3OnboardingTour = { startFirstRun: startFirstRun, armPendingFirstRun: armPendingFirstRun }; // tour-engine.js assigns window.slopsmithTour at script-eval time, so if it // is loaded before us register() succeeds immediately; otherwise retry once diff --git a/static/v3/profile.js b/static/v3/profile.js index 935e0e2..851fe0c 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -520,6 +520,12 @@ if (!editing && !finishOpts.launchingSong && window.v3OnboardingTour && typeof window.v3OnboardingTour.startFirstRun === 'function') { try { window.v3OnboardingTour.startFirstRun(); } catch (e) { /* never block onboarding */ } + } else if (!editing && finishOpts.launchingSong && + window.v3OnboardingTour && typeof window.v3OnboardingTour.armPendingFirstRun === 'function') { + // "Play it now" navigates straight to the player, so the home + // tour can't run now — arm it to fire the first time the user + // returns to home, instead of silently never showing. + try { window.v3OnboardingTour.armPendingFirstRun(); } catch (e) { /* never block onboarding */ } } }