Move MIDI preview controls into results table.

Adds per-row Play/Stop preview toggles, moves preview volume to the results header, and removes preview controls from the selected source card.
This commit is contained in:
b1rdmania
2025-12-26 19:27:16 +00:00
parent 9fc636a558
commit 7deb7bd4d9
2 changed files with 96 additions and 35 deletions
+12 -5
View File
@@ -413,6 +413,14 @@
} }
} }
.results-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing-unit) * 2);
flex-wrap: wrap;
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
}
.results-header h3 { margin: 0; } .results-header h3 { margin: 0; }
.confidence-bar { .confidence-bar {
@@ -944,6 +952,10 @@
<div id="resultsSection" class="results-section"> <div id="resultsSection" class="results-section">
<div class="results-header"> <div class="results-header">
<h3>Search Results</h3> <h3>Search Results</h3>
<div class="volume-control" style="margin-top:0; min-width: 260px; flex: 0 1 360px; opacity: 0.85;">
<label for="soundfontVolume" style="font-size: 12px;">Preview volume</label>
<input type="range" id="soundfontVolume" min="0" max="1" step="0.01" value="0.7" />
</div>
</div> </div>
<table id="resultsTable" class="results-table"> <table id="resultsTable" class="results-table">
<thead> <thead>
@@ -965,7 +977,6 @@
<div id="selectedMeta" class="selected-meta">Search for a song, then choose a source to use.</div> <div id="selectedMeta" class="selected-meta">Search for a song, then choose a source to use.</div>
<div class="selected-actions"> <div class="selected-actions">
<button id="previewBtn" type="button" disabled>Preview MIDI</button>
<button id="motifBtn" class="primary" disabled>Generate</button> <button id="motifBtn" class="primary" disabled>Generate</button>
</div> </div>
@@ -976,10 +987,6 @@
<label for="motifVolume">Output</label> <label for="motifVolume">Output</label>
<input type="range" id="motifVolume" min="0" max="1" step="0.01" value="0.7" /> <input type="range" id="motifVolume" min="0" max="1" step="0.01" value="0.7" />
</div> </div>
<div class="volume-control">
<label for="soundfontVolume">Preview</label>
<input type="range" id="soundfontVolume" min="0" max="1" step="0.01" value="0.7" />
</div>
</div> </div>
<div class="engine-line">Reimagined as classic game-console music using Motifs Wario engine.</div> <div class="engine-line">Reimagined as classic game-console music using Motifs Wario engine.</div>
+84 -30
View File
@@ -11,6 +11,9 @@ class MotifApp {
// Preview player (lazily created for iOS compatibility) // Preview player (lazily created for iOS compatibility)
private soundfontPlayer: SoundfontMIDIPlayer | null = null; private soundfontPlayer: SoundfontMIDIPlayer | null = null;
private playingPreviewIndex: number | null = null;
private previewStopTimeout: number | null = null;
private previewButtons: HTMLButtonElement[] = [];
private searchBtn!: HTMLButtonElement; private searchBtn!: HTMLButtonElement;
private songInput!: HTMLInputElement; private songInput!: HTMLInputElement;
@@ -26,7 +29,6 @@ class MotifApp {
// Preview player controls // Preview player controls
private soundfontVolumeSlider!: HTMLInputElement; private soundfontVolumeSlider!: HTMLInputElement;
private previewBtn!: HTMLButtonElement;
// Motif controls // Motif controls
private motifBtn!: HTMLButtonElement; private motifBtn!: HTMLButtonElement;
@@ -101,7 +103,6 @@ class MotifApp {
// Preview player controls // Preview player controls
this.soundfontVolumeSlider = document.getElementById('soundfontVolume') as HTMLInputElement; this.soundfontVolumeSlider = document.getElementById('soundfontVolume') as HTMLInputElement;
this.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement;
// Motif controls // Motif controls
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement; this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
@@ -141,8 +142,7 @@ class MotifApp {
} }
}); });
// Preview MIDI (verification only) // Preview MIDI (verification only) — controlled from results table rows
this.previewBtn.addEventListener('click', () => void this.handlePreviewMIDI());
this.soundfontVolumeSlider.addEventListener('input', (e) => { this.soundfontVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value); const volume = parseFloat((e.target as HTMLInputElement).value);
this.soundfontPlayer?.setVolume(volume); this.soundfontPlayer?.setVolume(volume);
@@ -295,6 +295,7 @@ class MotifApp {
private displayResults(): void { private displayResults(): void {
this.resultsBody.innerHTML = ''; this.resultsBody.innerHTML = '';
this.previewButtons = [];
this.searchResults.forEach((result, index) => { this.searchResults.forEach((result, index) => {
const row = document.createElement('tr'); const row = document.createElement('tr');
@@ -302,9 +303,21 @@ class MotifApp {
<td>${this.cleanSongTitle(result.title)}</td> <td>${this.cleanSongTitle(result.title)}</td>
<td class="source-col">${this.formatSourceLabel(result.source)}</td> <td class="source-col">${this.formatSourceLabel(result.source)}</td>
<td class="duration-col">${this.formatDuration(result.parsed?.durationSec)}</td> <td class="duration-col">${this.formatDuration(result.parsed?.durationSec)}</td>
<td class="action-col"><button type="button" class="row-use-btn">Use this</button></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>
</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 // Use explicit action button to reduce accidental selection
const useBtn = row.querySelector('button.row-use-btn') as HTMLButtonElement; const useBtn = row.querySelector('button.row-use-btn') as HTMLButtonElement;
useBtn.addEventListener('click', (e) => { useBtn.addEventListener('click', (e) => {
@@ -322,6 +335,72 @@ class MotifApp {
// No auto-select: user should choose "Use this" // 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(parseFloat(this.soundfontVolumeSlider.value));
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 {
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.
}
}
public async selectResult(index: number): Promise<void> { public async selectResult(index: number): Promise<void> {
if (index < 0 || index >= this.searchResults.length) return; if (index < 0 || index >= this.searchResults.length) return;
@@ -390,29 +469,6 @@ class MotifApp {
} }
} }
private async handlePreviewMIDI(): Promise<void> {
if (!this.currentMIDI) return;
try {
this.previewBtn.disabled = true;
const prevLabel = this.previewBtn.textContent || 'Preview MIDI';
this.previewBtn.textContent = 'Previewing…';
const player = await this.ensureAudioReady();
await player.load(this.currentMIDI.events);
player.setVolume(parseFloat(this.soundfontVolumeSlider.value));
await player.play();
const duration = player.getDuration();
window.setTimeout(() => {
this.previewBtn.disabled = false;
this.previewBtn.textContent = prevLabel;
}, Math.max(0.5, duration + 0.25) * 1000);
} catch {
this.previewBtn.disabled = false;
this.previewBtn.textContent = 'Preview MIDI';
}
}
// Motif handlers // Motif handlers
private async handleMotif(): Promise<void> { private async handleMotif(): Promise<void> {
if (!this.currentMIDI) { if (!this.currentMIDI) {
@@ -544,13 +600,11 @@ class MotifApp {
private enablePlayerControls(): void { private enablePlayerControls(): void {
this.motifBtn.disabled = false; this.motifBtn.disabled = false;
this.previewBtn.disabled = false;
this.copyLinkBtn.disabled = this.currentMIDI == null; this.copyLinkBtn.disabled = this.currentMIDI == null;
} }
private disablePlayerControls(): void { private disablePlayerControls(): void {
this.motifBtn.disabled = true; this.motifBtn.disabled = true;
this.previewBtn.disabled = true;
this.motifStopBtn.disabled = true; this.motifStopBtn.disabled = true;
this.copyLinkBtn.disabled = true; this.copyLinkBtn.disabled = true;
} }