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.
This commit is contained in:
b1rdmania
2025-12-26 20:38:15 +00:00
parent 839d40447f
commit fd422aa084
2 changed files with 159 additions and 88 deletions
+64 -7
View File
@@ -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 @@
<div id="status">Ready. Enter a song name to search for MIDI files.</div>
<div id="resultsSection" class="results-section">
<div id="chooseHelper" class="chooser-helper" style="display:none;">
<h4>Choosing a MIDI source</h4>
<p>
Different MIDI files capture songs differently. Some will produce better results than others.<br />
Pick a source, generate, and if it sounds off, try another.
</p>
</div>
<div class="results-header">
<h3>Search Results</h3>
</div>
@@ -978,14 +1026,23 @@
<div id="selectedCard" class="selected-card">
<h2 id="selectedTitle" class="selected-title">No source selected</h2>
<div id="selectedMeta" class="selected-meta">Search for a song, then choose a source to use.</div>
<div id="selectedExpectation" class="selected-meta" style="margin-top: 8px; color: rgba(255,255,255,0.62);">
Some MIDI files work better than others. If the result sounds off, try another source.
</div>
<div class="selected-actions">
<button id="motifBtn" class="primary" disabled>Generate</button>
<button id="motifBtn" class="primary" disabled>Generate music</button>
</div>
<div class="selected-support">
<button id="motifStopBtn" type="button" disabled>Stop</button>
<button id="copyLinkBtn" type="button" disabled>Copy link</button>
<button id="copyLinkBtn" type="button" disabled style="display:none;">Copy link</button>
<div class="preview-inline">
<button id="previewBtn" type="button" class="linklike" disabled>Preview original MIDI</button>
<span id="previewState" class="preview-state">Previewing…</span>
<button id="previewStopBtn" type="button" class="linklike" style="display:none;">Stop preview</button>
</div>
<button id="chooseDifferentBtn" type="button" class="linklike">Choose a different source</button>
</div>
<div class="engine-line">Reimagined as classic game-console music using Motifs Wario engine.</div>
+95 -81
View File
@@ -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 {
<td class="duration-col">${this.formatDuration(result.parsed?.durationSec)}</td>
<td class="action-col">
<div style="display:flex; gap: 10px; justify-content: flex-end; align-items:center;">
<button type="button" class="row-preview-btn">Play</button>
<button type="button" class="row-use-btn">Use this</button>
<button type="button" class="row-use-btn">Use this →</button>
</div>
</td>
`;
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<void> {
// 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<void> {
@@ -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<void> {
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<void> {
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<void> {
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;