From fd422aa0841ed64307c9e8b9d6a2d5d8b05d79bd Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Fri, 26 Dec 2025 20:38:15 +0000 Subject: [PATCH] Make results a chooser and move preview to selected card. Removes preview controls from search results, adds a 'Choosing a MIDI source' helper block, introduces selected/generated states, and keeps Preview original MIDI as a secondary action inside the selected source card. --- index.html | 71 ++++++++++++++++++--- src/main.ts | 176 ++++++++++++++++++++++++++++------------------------ 2 files changed, 159 insertions(+), 88 deletions(-) diff --git a/index.html b/index.html index d873760..a39acdc 100644 --- a/index.html +++ b/index.html @@ -332,6 +332,29 @@ .results-section.collapsed:hover { opacity: 1; } + + .chooser-helper { + margin-top: calc(var(--spacing-unit) * 2); + padding: calc(var(--spacing-unit) * 2); + background: rgba(0,0,0,0.18); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; + box-shadow: var(--shadow-sm); + } + .chooser-helper h4 { + margin: 0 0 6px 0; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255,255,255,0.85); + } + .chooser-helper p { + margin: 0; + font-size: 13px; + color: rgba(255,255,255,0.68); + line-height: 1.45; + max-width: 72ch; + } .results-table { width: 100%; @@ -486,11 +509,28 @@ flex-wrap: wrap; margin-top: 12px; } - .selected-support .volume-control { - margin-top: 0; - flex: 1; - min-width: 220px; - opacity: 0.85; + .selected-support .linklike { + background: transparent; + border: none; + padding: 0; + box-shadow: none; + color: rgba(0,255,136,0.9); + cursor: pointer; + font-size: 13px; + } + .selected-support .linklike:hover { + text-decoration: underline; + } + .preview-inline { + display: inline-flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + } + .preview-state { + font-size: 12px; + color: rgba(255,255,255,0.55); + display: none; } .engine-line { margin-top: 14px; @@ -957,6 +997,14 @@
Ready. Enter a song name to search for MIDI files.
+ +

Search Results

@@ -978,14 +1026,23 @@

No source selected

Search for a song, then choose a source to use.
+
+ Some MIDI files work better than others. If the result sounds off, try another source. +
- +
- + +
+ + Previewing… + +
+
Reimagined as classic game-console music using Motif’s Wario engine.
diff --git a/src/main.ts b/src/main.ts index 5d9ca2a..ccb41fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,9 +11,7 @@ class MotifApp { // Preview player (lazily created for iOS compatibility) private soundfontPlayer: SoundfontMIDIPlayer | null = null; - private playingPreviewIndex: number | null = null; private previewStopTimeout: number | null = null; - private previewButtons: HTMLButtonElement[] = []; private searchBtn!: HTMLButtonElement; private songInput!: HTMLInputElement; @@ -22,10 +20,18 @@ class MotifApp { private resultsSection!: HTMLElement; private resultsBody!: HTMLElement; private playerSection!: HTMLElement; + private chooseHelper!: HTMLElement; private selectedTitle!: HTMLElement; private selectedMeta!: HTMLElement; private selectedDetails!: HTMLElement; + private previewBtn!: HTMLButtonElement; + private previewStopBtn!: HTMLButtonElement; + private previewState!: HTMLElement; + private chooseDifferentBtn!: HTMLButtonElement; + + private isPreviewPlaying = false; + private hasGenerated = false; // Preview player (no UI volume) @@ -95,10 +101,15 @@ class MotifApp { this.resultsSection = document.getElementById('resultsSection')!; this.resultsBody = document.getElementById('resultsBody')!; this.playerSection = document.getElementById('playerSection')!; + this.chooseHelper = document.getElementById('chooseHelper')!; this.selectedTitle = document.getElementById('selectedTitle')!; this.selectedMeta = document.getElementById('selectedMeta')!; this.selectedDetails = document.getElementById('selectedDetails')!; + this.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement; + this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement; + this.previewState = document.getElementById('previewState')!; + this.chooseDifferentBtn = document.getElementById('chooseDifferentBtn') as HTMLButtonElement; // Motif controls this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement; @@ -143,6 +154,11 @@ class MotifApp { // Motif this.motifBtn.addEventListener('click', () => this.handleMotif()); this.motifStopBtn.addEventListener('click', () => this.handleMotifStop()); + + // Preview inside selected source only + this.previewBtn.addEventListener('click', () => void this.handlePreviewToggle()); + this.previewStopBtn.addEventListener('click', () => this.stopPreview()); + this.chooseDifferentBtn.addEventListener('click', () => this.goToResults()); // Use both input and change for iOS compatibility const seekHandler = (e: Event) => { const progress = parseFloat((e.target as HTMLInputElement).value) / 100; @@ -249,7 +265,7 @@ class MotifApp { this.updateStatus('Searching…'); this.searchBtn.disabled = true; - this.hideResults(); + this.setState('idle'); try { const results = await this.midiService.search(songName); @@ -272,6 +288,7 @@ class MotifApp { } this.displayResults(); + this.setState('results'); this.updateStatus('Pick a source to use.'); this.updateIOSAudioBanner(); @@ -284,7 +301,6 @@ class MotifApp { private displayResults(): void { this.resultsBody.innerHTML = ''; - this.previewButtons = []; this.searchResults.forEach((result, index) => { const row = document.createElement('tr'); @@ -294,19 +310,11 @@ class MotifApp { ${this.formatDuration(result.parsed?.durationSec)}
- - +
`; - const previewBtn = row.querySelector('button.row-preview-btn') as HTMLButtonElement; - previewBtn.addEventListener('click', (e) => { - e.stopPropagation(); - void this.handleRowPreview(index); - }); - this.previewButtons[index] = previewBtn; - // Use explicit action button to reduce accidental selection const useBtn = row.querySelector('button.row-use-btn') as HTMLButtonElement; useBtn.addEventListener('click', (e) => { @@ -317,77 +325,19 @@ class MotifApp { this.resultsBody.appendChild(row); }); - this.resultsSection.classList.add('visible'); - this.resultsSection.classList.remove('collapsed'); - this.playerSection.classList.remove('visible'); - // No auto-select: user should choose "Use this" } - private updatePreviewButtons(): void { - for (let i = 0; i < this.previewButtons.length; i++) { - const btn = this.previewButtons[i]; - if (!btn) continue; - btn.textContent = this.playingPreviewIndex === i ? 'Stop' : 'Play'; - } - } - - private async handleRowPreview(index: number): Promise { - // Toggle stop if same row - if (this.playingPreviewIndex === index) { - this.stopPreview(true); - return; - } - - // Stop any existing preview first - this.stopPreview(false); - - const result = this.searchResults[index]; - if (!result?.midiUrl) return; - - try { - const player = await this.ensureAudioReady(); - - // Lazy-load events for preview without affecting selection state. - let events: NoteEvent[] | null = (result as any).__previewEvents || null; - if (!events) { - const midiBuffer = await this.midiService.fetchMIDI(result.midiUrl); - if (!midiBuffer) throw new Error('Failed to fetch MIDI'); - events = MIDIParser.parseMIDI(midiBuffer); - (result as any).__previewEvents = events; - } - - await player.load(events); - player.setVolume(1); - await player.play(); - - this.playingPreviewIndex = index; - this.updatePreviewButtons(); - - // Best-effort: reset UI after playback ends (SoundfontMIDIPlayer self-stops) - const duration = player.getDuration(); - if (this.previewStopTimeout) window.clearTimeout(this.previewStopTimeout); - this.previewStopTimeout = window.setTimeout(() => { - if (this.playingPreviewIndex === index) { - this.stopPreview(false); - } - }, Math.max(0.5, duration + 0.5) * 1000); - } catch { - this.stopPreview(false); - } - } - - private stopPreview(updateStatus: boolean): void { + private stopPreview(): void { if (this.previewStopTimeout) { window.clearTimeout(this.previewStopTimeout); this.previewStopTimeout = null; } this.soundfontPlayer?.stop(); - this.playingPreviewIndex = null; - this.updatePreviewButtons(); - if (updateStatus) { - // No status spam in main flow. - } + this.isPreviewPlaying = false; + this.previewState.style.display = 'none'; + this.previewStopBtn.style.display = 'none'; + this.previewBtn.disabled = this.currentMIDI == null; } public async selectResult(index: number): Promise { @@ -397,6 +347,8 @@ class MotifApp { this.handleMotifStop(); // Stop any playing preview audio this.soundfontPlayer?.stop(); + this.stopPreview(); + this.hasGenerated = false; this.selectedResultIndex = index; const result = this.searchResults[index]; @@ -452,6 +404,7 @@ class MotifApp { this.resultsSection.classList.add('collapsed'); this.enablePlayerControls(); this.updateStatus(''); + this.setState('selected'); // Intent: after selecting "Use this", bring the workbench controls into view. // Do this after the card is visible and MIDI is loaded. @@ -466,6 +419,38 @@ class MotifApp { } } + private async handlePreviewToggle(): Promise { + if (!this.currentMIDI) return; + if (this.isPreviewPlaying) { + this.stopPreview(); + return; + } + + // Audio exclusivity + this.handleMotifStop(); + + try { + this.previewBtn.disabled = true; + const player = await this.ensureAudioReady(); + await player.load(this.currentMIDI.events); + player.setVolume(1); + await player.play(); + + this.isPreviewPlaying = true; + this.previewState.style.display = 'inline'; + this.previewStopBtn.style.display = 'inline'; + this.previewBtn.disabled = false; + + const duration = player.getDuration(); + if (this.previewStopTimeout) window.clearTimeout(this.previewStopTimeout); + this.previewStopTimeout = window.setTimeout(() => { + this.stopPreview(); + }, Math.max(0.5, duration + 0.5) * 1000); + } catch { + this.stopPreview(); + } + } + // Motif handlers private async handleMotif(): Promise { if (!this.currentMIDI) { @@ -473,6 +458,8 @@ class MotifApp { } try { + // Audio exclusivity + this.stopPreview(); // Best-effort: ensure iOS audio is unlocked from this user gesture. await unlockAudio(); this.updateIOSAudioBanner(); @@ -499,6 +486,8 @@ class MotifApp { this.startMotifProgressUpdates(); this.updateStatus(''); + this.hasGenerated = true; + this.setState('generated'); } catch (error) { this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`); this.motifBtn.disabled = false; @@ -591,20 +580,17 @@ class MotifApp { return this.titleCase(s); } - private hideResults(): void { - this.resultsSection.classList.remove('visible'); - this.playerSection.classList.remove('visible'); - } - private enablePlayerControls(): void { this.motifBtn.disabled = false; this.copyLinkBtn.disabled = this.currentMIDI == null; + this.previewBtn.disabled = this.currentMIDI == null; } private disablePlayerControls(): void { this.motifBtn.disabled = true; this.motifStopBtn.disabled = true; this.copyLinkBtn.disabled = true; + this.previewBtn.disabled = true; } private updateStatus(message: string): void { @@ -638,6 +624,7 @@ class MotifApp { } private async handleCopyLink(): Promise { + if (!this.hasGenerated) return; const result = this.searchResults[this.selectedResultIndex]; if (!result?.midiUrl) return; @@ -694,6 +681,33 @@ class MotifApp { } } + private setState(state: 'idle' | 'results' | 'selected' | 'generated'): void { + // results + const hasResults = state === 'results' || state === 'selected' || state === 'generated'; + this.resultsSection.classList.toggle('visible', hasResults); + this.chooseHelper.style.display = state === 'results' ? 'block' : 'none'; + + // selected card + const hasSelection = state === 'selected' || state === 'generated'; + this.playerSection.classList.toggle('visible', hasSelection); + + // share link only after generation + this.copyLinkBtn.style.display = state === 'generated' ? 'inline-block' : 'none'; + this.copyLinkBtn.disabled = state !== 'generated'; + + // collapse results when selected/generated + this.resultsSection.classList.toggle('collapsed', state === 'selected' || state === 'generated'); + } + + private goToResults(): void { + this.setState('results'); + try { + this.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch { + // ignore + } + } + private updateEmbedSnippet(songTitle: string): void { if (!this.embedSection || !this.embedCodeEl) return;