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:
+64
-7
@@ -333,6 +333,29 @@
|
||||
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%;
|
||||
border-collapse: collapse;
|
||||
@@ -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 Motif’s Wario engine.</div>
|
||||
|
||||
+95
-81
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user