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:
+12
-5
@@ -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 Motif’s Wario engine.</div>
|
<div class="engine-line">Reimagined as classic game-console music using Motif’s Wario engine.</div>
|
||||||
|
|||||||
+84
-30
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user