456db201e5
Offer full-song export via prompt and improve the fallback save path by using Share Sheet when available or opening the MP3 in a new tab on iOS, instead of relying on unreliable blob downloads. Made-with: Cursor
1256 lines
44 KiB
TypeScript
1256 lines
44 KiB
TypeScript
import { MotifEngine } from './core/MotifEngine';
|
|
import { MIDIService } from './services/MIDIService';
|
|
import { MIDIParser } from './midi/MIDIParser';
|
|
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
|
import { getAudioContext, isAudioReady, peekAudioContext, unlockAudio } from './utils/audioUnlock';
|
|
import type { NoteEvent } from './types';
|
|
import * as lamejs from 'lamejs';
|
|
|
|
class MotifApp {
|
|
private motifEngine: MotifEngine;
|
|
private midiService: MIDIService;
|
|
|
|
// Preview player (lazily created for iOS compatibility)
|
|
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
|
|
private previewStopTimeout: number | null = null;
|
|
|
|
private searchBtn!: HTMLButtonElement;
|
|
private songInput!: HTMLInputElement;
|
|
private status!: HTMLElement;
|
|
|
|
private resultsSection!: HTMLElement;
|
|
private resultsBody!: HTMLElement;
|
|
private playerSection!: HTMLElement;
|
|
private chooseHelper!: HTMLElement;
|
|
|
|
private selectedTitle!: HTMLElement;
|
|
private selectedMeta!: HTMLElement;
|
|
private preGenActions!: HTMLElement;
|
|
private preGenSupport!: HTMLElement;
|
|
private generatedBlock!: HTMLElement;
|
|
private playPauseBtn!: HTMLButtonElement;
|
|
private previewBtn!: HTMLButtonElement;
|
|
private previewStopBtn!: HTMLButtonElement;
|
|
private previewState!: HTMLElement;
|
|
|
|
private isPreviewPlaying = false;
|
|
private hasGenerated = false;
|
|
private isMotifPlaying = false;
|
|
private motifResumeProgress = 0;
|
|
|
|
// Preview player (no UI volume)
|
|
|
|
// Motif controls
|
|
private motifBtn!: HTMLButtonElement;
|
|
private motifProgressContainer!: HTMLElement;
|
|
private motifProgressBar!: HTMLInputElement;
|
|
private motifProgressFill!: HTMLElement;
|
|
private motifCurrentTime!: HTMLElement;
|
|
private motifDuration!: HTMLElement;
|
|
private motifProgressInterval: number | null = null;
|
|
|
|
// iOS audio unlock UI (Motif only)
|
|
private iosAudioBanner!: HTMLElement;
|
|
private enableAudioBtn!: HTMLButtonElement;
|
|
private iosAudioState!: HTMLElement;
|
|
|
|
private copyLinkBtn!: HTMLButtonElement;
|
|
private shareToXBtn!: HTMLButtonElement;
|
|
private downloadMp3Btn!: HTMLButtonElement;
|
|
private shareFallback!: HTMLElement;
|
|
private shareFallbackInput!: HTMLInputElement;
|
|
|
|
private downloadMp3BtnLabel = 'Download MP3';
|
|
private preparedMp3: { url: string; filename: string; blob: Blob } | null = null;
|
|
private preparedMp3RevokeTimeout: number | null = null;
|
|
private isMp3Preparing = false;
|
|
|
|
// Embed snippet UI
|
|
private embedSection: HTMLElement | null = null;
|
|
private embedCodeEl: HTMLElement | null = null;
|
|
private copyEmbedBtn: HTMLButtonElement | null = null;
|
|
private copyToast: HTMLElement | null = null;
|
|
|
|
// FAQ modal
|
|
private faqBtnTop: HTMLButtonElement | null = null;
|
|
private newSearchBtn: HTMLButtonElement | null = null;
|
|
private faqBackdrop!: HTMLElement;
|
|
private faqCloseBtn!: HTMLButtonElement;
|
|
|
|
private searchResults: any[] = [];
|
|
private selectedResultIndex = 0;
|
|
private currentMIDI: { events: NoteEvent[], metadata: any } | null = null;
|
|
|
|
constructor() {
|
|
this.motifEngine = new MotifEngine();
|
|
this.midiService = new MIDIService();
|
|
// soundfontPlayer created lazily on first play for iOS compatibility
|
|
|
|
this.initializeUI();
|
|
this.setupEventListeners();
|
|
|
|
// Always play at 100%
|
|
this.motifEngine.setVolume(1);
|
|
}
|
|
|
|
/**
|
|
* Ensure audio is unlocked and soundfontPlayer is ready.
|
|
* Must be called from a user gesture context.
|
|
*/
|
|
private async ensureAudioReady(): Promise<SoundfontMIDIPlayer> {
|
|
const audioContext = await unlockAudio();
|
|
if (!this.soundfontPlayer) {
|
|
this.soundfontPlayer = new SoundfontMIDIPlayer(audioContext);
|
|
this.soundfontPlayer.setVolume(1);
|
|
}
|
|
return this.soundfontPlayer;
|
|
}
|
|
|
|
private initializeUI(): void {
|
|
this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
|
|
this.songInput = document.getElementById('songInput') as HTMLInputElement;
|
|
this.status = document.getElementById('status')!;
|
|
|
|
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.preGenActions = document.getElementById('preGenActions')!;
|
|
this.preGenSupport = document.getElementById('preGenSupport')!;
|
|
this.generatedBlock = document.getElementById('generatedBlock')!;
|
|
this.playPauseBtn = document.getElementById('playPauseBtn') as HTMLButtonElement;
|
|
this.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement;
|
|
this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement;
|
|
this.previewState = document.getElementById('previewState')!;
|
|
|
|
// Motif controls
|
|
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
|
this.motifProgressContainer = document.getElementById('motifProgressContainer')!;
|
|
this.motifProgressBar = document.getElementById('motifProgressBar') as HTMLInputElement;
|
|
this.motifProgressFill = document.getElementById('motifProgressFill')!;
|
|
this.motifCurrentTime = document.getElementById('motifCurrentTime')!;
|
|
this.motifDuration = document.getElementById('motifDuration')!;
|
|
|
|
this.copyLinkBtn = document.getElementById('copyLinkBtn') as HTMLButtonElement;
|
|
this.shareToXBtn = document.getElementById('shareToXBtn') as HTMLButtonElement;
|
|
this.downloadMp3Btn = document.getElementById('downloadMp3Btn') as HTMLButtonElement;
|
|
this.downloadMp3BtnLabel = (this.downloadMp3Btn.textContent || '').trim() || 'Download MP3';
|
|
this.shareFallback = document.getElementById('shareFallback')!;
|
|
this.shareFallbackInput = document.getElementById('shareFallbackInput') as HTMLInputElement;
|
|
|
|
// iOS audio unlock UI (Motif)
|
|
this.iosAudioBanner = document.getElementById('iosAudioBanner')!;
|
|
this.enableAudioBtn = document.getElementById('enableAudioBtn') as HTMLButtonElement;
|
|
this.iosAudioState = document.getElementById('iosAudioState')!;
|
|
|
|
// Optional embed UI (only present on main page)
|
|
this.embedSection = document.getElementById('embedSection');
|
|
this.embedCodeEl = document.getElementById('embedCode');
|
|
this.copyEmbedBtn = document.getElementById('copyEmbedBtn') as HTMLButtonElement | null;
|
|
this.copyToast = document.getElementById('copyToast');
|
|
|
|
// FAQ modal
|
|
this.faqBtnTop = document.getElementById('faqBtnTop') as HTMLButtonElement | null;
|
|
this.newSearchBtn = document.getElementById('newSearchBtn') as HTMLButtonElement | null;
|
|
this.faqBackdrop = document.getElementById('faqModalBackdrop')!;
|
|
this.faqCloseBtn = document.getElementById('faqCloseBtn') as HTMLButtonElement;
|
|
}
|
|
|
|
private setupEventListeners(): void {
|
|
const doSearch = () => {
|
|
console.log('[MotifApp] Search triggered');
|
|
// Immediately show feedback so user knows click registered
|
|
this.status.textContent = 'Starting search...';
|
|
this.handleSearch().catch(err => {
|
|
console.error('[MotifApp] Search error:', err);
|
|
this.updateStatus(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
});
|
|
};
|
|
|
|
// Use both click and touchend for iOS compatibility
|
|
this.searchBtn.addEventListener('click', doSearch);
|
|
|
|
this.songInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
doSearch();
|
|
}
|
|
});
|
|
|
|
// Preview MIDI (verification only) — controlled from results table rows
|
|
|
|
// Motif
|
|
this.motifBtn.addEventListener('click', () => this.handleMotif());
|
|
this.playPauseBtn.addEventListener('click', () => void this.handlePlayPause());
|
|
|
|
// Preview inside selected source only
|
|
this.previewBtn.addEventListener('click', () => void this.handlePreviewToggle());
|
|
this.previewStopBtn.addEventListener('click', () => this.stopPreview());
|
|
// Use both input and change for iOS compatibility
|
|
const seekHandler = (e: Event) => {
|
|
const progress = parseFloat((e.target as HTMLInputElement).value) / 100;
|
|
this.handleMotifSeek(progress);
|
|
};
|
|
this.motifProgressBar.addEventListener('input', seekHandler);
|
|
this.motifProgressBar.addEventListener('change', seekHandler);
|
|
|
|
this.copyLinkBtn.addEventListener('click', () => void this.handleCopyLink());
|
|
this.shareToXBtn.addEventListener('click', () => void this.handleShareToX());
|
|
this.downloadMp3Btn.addEventListener('click', () => void this.handleDownloadMp3());
|
|
|
|
// Embed snippet copy (may be disabled / not-live)
|
|
this.copyEmbedBtn?.addEventListener('click', () => void this.copyEmbedSnippet());
|
|
|
|
// iOS audio unlock CTA — must be a user gesture
|
|
const enable = () => void this.handleEnableAudio();
|
|
this.enableAudioBtn.addEventListener('click', enable);
|
|
this.enableAudioBtn.addEventListener('touchend', enable, { passive: true });
|
|
|
|
// FAQ
|
|
this.faqBtnTop?.addEventListener('click', () => this.openFaq());
|
|
this.newSearchBtn?.addEventListener('click', () => this.resetToNewSearch());
|
|
this.faqCloseBtn.addEventListener('click', () => this.closeFaq());
|
|
this.faqBackdrop.addEventListener('click', (e) => {
|
|
if (e.target === this.faqBackdrop) this.closeFaq();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') this.closeFaq();
|
|
});
|
|
}
|
|
|
|
private openFaq(): void {
|
|
this.faqBackdrop.classList.add('open');
|
|
// focus close for keyboard users
|
|
this.faqCloseBtn.focus();
|
|
}
|
|
|
|
private closeFaq(): void {
|
|
this.faqBackdrop.classList.remove('open');
|
|
}
|
|
|
|
private resetToNewSearch(): void {
|
|
// Stop all audio and return to the search workbench.
|
|
this.stopPreview();
|
|
this.handleMotifStop();
|
|
this.clearPreparedMp3();
|
|
this.hasGenerated = false;
|
|
this.isMotifPlaying = false;
|
|
this.motifResumeProgress = 0;
|
|
this.currentMIDI = null;
|
|
|
|
this.setState('idle');
|
|
this.updateStatus('Ready. Search any song to make a Game Boy version.');
|
|
|
|
try {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
// Focus search field for immediate next query
|
|
window.setTimeout(() => {
|
|
try {
|
|
this.songInput.focus();
|
|
this.songInput.select();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 150);
|
|
}
|
|
|
|
private isIOSLike(): boolean {
|
|
const ua = navigator.userAgent || '';
|
|
const iOS = /iPad|iPhone|iPod/.test(ua);
|
|
const iPadOS13Plus = /Macintosh/.test(ua) && (navigator as any).maxTouchPoints > 1;
|
|
return iOS || iPadOS13Plus;
|
|
}
|
|
|
|
private updateIOSAudioBanner(): void {
|
|
// Only show this UX on iOS-like browsers, and only until audio is running.
|
|
if (!this.isIOSLike()) {
|
|
this.iosAudioBanner.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const ready = isAudioReady();
|
|
this.iosAudioBanner.style.display = ready ? 'none' : 'block';
|
|
|
|
// Optional tiny state readout (helps support debugging)
|
|
const ctx = peekAudioContext();
|
|
if (!ready && ctx) {
|
|
this.iosAudioState.style.display = 'block';
|
|
this.iosAudioState.textContent = `Audio: ${ctx.state} @ ${ctx.sampleRate}Hz`;
|
|
} else {
|
|
this.iosAudioState.style.display = 'none';
|
|
this.iosAudioState.textContent = '';
|
|
}
|
|
}
|
|
|
|
private async handleEnableAudio(): Promise<void> {
|
|
// Must run in a user gesture context.
|
|
try {
|
|
this.enableAudioBtn.disabled = true;
|
|
this.iosAudioState.style.display = 'block';
|
|
this.iosAudioState.textContent = 'Audio: enabling…';
|
|
|
|
await unlockAudio();
|
|
|
|
// Update banner state
|
|
const ctx = getAudioContext();
|
|
if (ctx.state !== 'running') {
|
|
this.enableAudioBtn.disabled = false;
|
|
this.iosAudioState.textContent = 'Audio still locked. Tap Enable Audio again.';
|
|
return;
|
|
}
|
|
|
|
this.iosAudioState.textContent = `Audio: running @ ${ctx.sampleRate}Hz`;
|
|
// Hide after a short beat to reduce flicker
|
|
window.setTimeout(() => this.updateIOSAudioBanner(), 250);
|
|
} catch {
|
|
this.enableAudioBtn.disabled = false;
|
|
this.iosAudioState.style.display = 'block';
|
|
this.iosAudioState.textContent = 'Audio enable failed. Tap again, or disable Silent Mode.';
|
|
} finally {
|
|
this.enableAudioBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
private async handleSearch(): Promise<void> {
|
|
console.log('[MotifApp] handleSearch called');
|
|
const songName = this.songInput.value.trim();
|
|
console.log('[MotifApp] songName:', songName);
|
|
if (!songName) {
|
|
this.updateStatus('Enter a song name to search.');
|
|
this.songInput.focus();
|
|
return;
|
|
}
|
|
|
|
// Stop any playing Motif audio
|
|
this.handleMotifStop();
|
|
|
|
this.updateStatus('Searching…');
|
|
console.log('[MotifApp] Starting search for:', songName);
|
|
this.searchBtn.disabled = true;
|
|
this.setState('idle');
|
|
|
|
try {
|
|
const results = await this.midiService.search(songName);
|
|
|
|
if (results.length === 0) {
|
|
this.updateStatus('No MIDI files found. Try a different search.');
|
|
return;
|
|
}
|
|
|
|
this.searchResults = results;
|
|
this.selectedResultIndex = 0;
|
|
|
|
// Parse metadata for results
|
|
this.updateStatus('Analyzing MIDI files...');
|
|
for (let i = 0; i < Math.min(results.length, 3); i++) {
|
|
const metadata = await this.midiService.parseMIDI(results[i].midiUrl);
|
|
if (metadata) {
|
|
results[i].parsed = metadata;
|
|
}
|
|
}
|
|
|
|
this.displayResults();
|
|
this.setState('results');
|
|
this.updateStatus('Pick a midi source. Try another if it sounds bad, some work better than others.');
|
|
this.updateIOSAudioBanner();
|
|
|
|
} catch (error) {
|
|
this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
} finally {
|
|
this.searchBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
private displayResults(): void {
|
|
this.resultsBody.innerHTML = '';
|
|
|
|
this.searchResults.forEach((result, index) => {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${this.cleanSongTitle(result.title)}</td>
|
|
<td class="source-col">${this.formatSourceLabel(result.source)}</td>
|
|
<td class="action-col">
|
|
<div style="display:flex; gap: 10px; justify-content: flex-end; align-items:center;">
|
|
<button type="button" class="row-use-btn">Use</button>
|
|
</div>
|
|
</td>
|
|
`;
|
|
|
|
// Use explicit action button to reduce accidental selection
|
|
const useBtn = row.querySelector('button.row-use-btn') as HTMLButtonElement;
|
|
useBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
void this.selectResult(index);
|
|
});
|
|
|
|
this.resultsBody.appendChild(row);
|
|
});
|
|
|
|
// No auto-select: user should choose "Use this"
|
|
}
|
|
|
|
private stopPreview(): void {
|
|
if (this.previewStopTimeout) {
|
|
window.clearTimeout(this.previewStopTimeout);
|
|
this.previewStopTimeout = null;
|
|
}
|
|
this.soundfontPlayer?.stop();
|
|
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> {
|
|
if (index < 0 || index >= this.searchResults.length) return;
|
|
|
|
// Stop any playing Motif audio
|
|
this.handleMotifStop();
|
|
// Stop any playing preview audio
|
|
this.soundfontPlayer?.stop();
|
|
this.stopPreview();
|
|
this.clearPreparedMp3();
|
|
this.hasGenerated = false;
|
|
|
|
this.selectedResultIndex = index;
|
|
const result = this.searchResults[index];
|
|
|
|
// Update selection highlighting
|
|
const rows = this.resultsBody.querySelectorAll('tr');
|
|
rows.forEach((row, i) => {
|
|
row.classList.toggle('selected', i === index);
|
|
});
|
|
|
|
this.updateStatus('Loading…');
|
|
this.disablePlayerControls();
|
|
|
|
try {
|
|
// Fetch and parse MIDI
|
|
const midiBuffer = await this.midiService.fetchMIDI(result.midiUrl);
|
|
if (!midiBuffer) {
|
|
throw new Error('Failed to fetch MIDI file');
|
|
}
|
|
|
|
const events = MIDIParser.parseMIDI(midiBuffer);
|
|
const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer);
|
|
|
|
// Calculate duration from events if metadata duration is 0 or missing
|
|
let actualDuration = metadata.duration || metadata.durationSec || 0;
|
|
if (actualDuration === 0 && events.length > 0) {
|
|
// Calculate duration from the last event
|
|
actualDuration = Math.max(...events.map(e => e.time + e.duration));
|
|
}
|
|
|
|
this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
|
|
|
|
// Update UI
|
|
const displayTitle = this.cleanSongTitle(result.title);
|
|
this.selectedTitle.textContent = displayTitle;
|
|
this.selectedMeta.textContent = `Source: ${this.formatSourceLabel(result.source)}`;
|
|
|
|
this.updateEmbedSnippet(result.title);
|
|
this.updateIOSAudioBanner();
|
|
|
|
this.playerSection.classList.add('visible');
|
|
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.
|
|
try {
|
|
this.playerSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
} catch (error) {
|
|
this.updateStatus(`Load error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
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);
|
|
// conservative fixed preview level
|
|
player.setVolume(0.8);
|
|
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) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Audio exclusivity
|
|
this.stopPreview();
|
|
this.clearPreparedMp3();
|
|
// Best-effort: ensure iOS audio is unlocked from this user gesture.
|
|
await unlockAudio();
|
|
this.updateIOSAudioBanner();
|
|
|
|
// Preserve scroll position during state change
|
|
const scrollY = window.scrollY;
|
|
// Once generation starts: lock into generated experience mode.
|
|
this.setState('generated');
|
|
window.scrollTo(0, scrollY);
|
|
this.updateStatus('Generating…');
|
|
this.motifBtn.disabled = true;
|
|
this.playPauseBtn.disabled = true;
|
|
|
|
// Generate a variation using the procedural role-mapping mode
|
|
await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural');
|
|
this.motifEngine.setVolume(0.8);
|
|
|
|
await this.motifEngine.play();
|
|
|
|
this.isMotifPlaying = true;
|
|
this.playPauseBtn.disabled = false;
|
|
this.playPauseBtn.textContent = 'Pause';
|
|
|
|
// Show progress bar and set duration
|
|
this.motifProgressContainer.style.display = 'block';
|
|
const duration = this.motifEngine.getDuration();
|
|
this.motifDuration.textContent = this.formatTime(duration);
|
|
this.motifProgressBar.value = '0';
|
|
this.motifProgressFill.style.width = '0%';
|
|
|
|
// Start progress updates
|
|
this.startMotifProgressUpdates();
|
|
|
|
this.updateStatus('');
|
|
this.hasGenerated = true;
|
|
// Refresh generated state now that generation succeeded (enables Copy link, etc.)
|
|
const scrollYEnd = window.scrollY;
|
|
this.setState('generated');
|
|
window.scrollTo(0, scrollYEnd);
|
|
} catch (error) {
|
|
this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
this.motifBtn.disabled = false;
|
|
this.playPauseBtn.disabled = false;
|
|
// If generation fails, return to selected state
|
|
this.setState('selected');
|
|
}
|
|
}
|
|
|
|
private handleMotifStop(): void {
|
|
this.motifEngine.stop();
|
|
this.isMotifPlaying = false;
|
|
this.playPauseBtn.textContent = 'Play';
|
|
this.stopMotifProgressUpdates();
|
|
this.updateStatus('');
|
|
}
|
|
|
|
private handleMotifSeek(progress: number): void {
|
|
this.motifResumeProgress = progress;
|
|
if (this.isMotifPlaying) {
|
|
this.motifEngine.seek(progress);
|
|
this.updateMotifProgress();
|
|
return;
|
|
}
|
|
const duration = this.motifEngine.getDuration();
|
|
this.motifProgressBar.value = (progress * 100).toString();
|
|
this.motifProgressFill.style.width = `${progress * 100}%`;
|
|
this.motifCurrentTime.textContent = this.formatTime(progress * duration);
|
|
}
|
|
|
|
private startMotifProgressUpdates(): void {
|
|
this.stopMotifProgressUpdates();
|
|
this.motifProgressInterval = window.setInterval(() => {
|
|
this.updateMotifProgress();
|
|
}, 100); // Update 10 times per second
|
|
}
|
|
|
|
private stopMotifProgressUpdates(): void {
|
|
if (this.motifProgressInterval !== null) {
|
|
clearInterval(this.motifProgressInterval);
|
|
this.motifProgressInterval = null;
|
|
}
|
|
}
|
|
|
|
private updateMotifProgress(): void {
|
|
const progress = this.motifEngine.getProgress();
|
|
const currentTime = this.motifEngine.getCurrentTime();
|
|
|
|
this.motifProgressBar.value = (progress * 100).toString();
|
|
this.motifProgressFill.style.width = `${progress * 100}%`;
|
|
this.motifCurrentTime.textContent = this.formatTime(currentTime);
|
|
|
|
// Auto-pause at end
|
|
const duration = this.motifEngine.getDuration();
|
|
if (duration > 0 && progress >= 0.999) {
|
|
this.motifResumeProgress = 0;
|
|
this.handleMotifStop();
|
|
this.motifCurrentTime.textContent = this.formatTime(duration);
|
|
this.motifProgressBar.value = '100';
|
|
this.motifProgressFill.style.width = '100%';
|
|
}
|
|
}
|
|
|
|
private formatTime(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
private titleCase(input: string): string {
|
|
const small = new Set([
|
|
'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'into',
|
|
'nor', 'of', 'on', 'or', 'per', 'the', 'to', 'via', 'with',
|
|
]);
|
|
const words = (input || '')
|
|
.split(/\s+/g)
|
|
.filter(Boolean)
|
|
.map((w) => w.trim());
|
|
return words
|
|
.map((word, idx) => {
|
|
if (/^[A-Z0-9]+$/.test(word)) return word;
|
|
const lower = word.toLowerCase();
|
|
if (idx !== 0 && small.has(lower)) return lower;
|
|
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
})
|
|
.join(' ');
|
|
}
|
|
|
|
private cleanSongTitle(raw: string): string {
|
|
const cleaned = (raw || '')
|
|
.replace(/\.mid$/i, '')
|
|
.replace(/[_]+/g, ' ')
|
|
.replace(/[.]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
// Strip standalone "mid" or "midi" words (case insensitive)
|
|
.replace(/\b(midi?)\b/gi, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return this.titleCase(cleaned || 'Unknown');
|
|
}
|
|
|
|
private formatSourceLabel(source: string): string {
|
|
const s = String(source || '').toLowerCase();
|
|
if (s === 'bitmidi') return 'BitMidi';
|
|
if (s === 'dongrays') return 'Dongrays';
|
|
return this.titleCase(s);
|
|
}
|
|
|
|
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.copyLinkBtn.disabled = true;
|
|
this.previewBtn.disabled = true;
|
|
}
|
|
|
|
private updateStatus(message: string): void {
|
|
this.status.textContent = message;
|
|
}
|
|
|
|
private async handleCopyLink(): Promise<void> {
|
|
if (!this.hasGenerated) return;
|
|
const result = this.searchResults[this.selectedResultIndex];
|
|
if (!result?.midiUrl) return;
|
|
|
|
const title = result.title || '';
|
|
|
|
// Try to get a short link with dynamic OG tags
|
|
let shareUrl: string | null = null;
|
|
try {
|
|
let payload: any = null;
|
|
if (result.source === 'bitmidi') {
|
|
const m = String(result.midiUrl).match(/\/uploads\/(\d+)\.mid/i);
|
|
if (m?.[1]) payload = { src: 'bitmidi', id: m[1], title };
|
|
}
|
|
if (!payload) payload = { u: result.midiUrl, title };
|
|
|
|
const resp = await fetch('/api/share', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (data?.url) shareUrl = `${window.location.origin}${data.url}`;
|
|
}
|
|
} catch {
|
|
// Fall through to long URL
|
|
}
|
|
|
|
// Fallback: direct /play link
|
|
if (!shareUrl) {
|
|
const encodedTitle = encodeURIComponent(title);
|
|
if (result.source === 'bitmidi') {
|
|
const m = String(result.midiUrl).match(/\/uploads\/(\d+)\.mid/i);
|
|
if (m?.[1]) {
|
|
shareUrl = `${window.location.origin}/play?src=bitmidi&id=${encodeURIComponent(m[1])}&title=${encodedTitle}`;
|
|
} else {
|
|
shareUrl = `${window.location.origin}/play?u=${encodeURIComponent(result.midiUrl)}&title=${encodedTitle}`;
|
|
}
|
|
} else {
|
|
shareUrl = `${window.location.origin}/play?u=${encodeURIComponent(result.midiUrl)}&title=${encodedTitle}`;
|
|
}
|
|
}
|
|
|
|
// Try to copy to clipboard
|
|
let copied = false;
|
|
|
|
// Method 1: Modern Clipboard API
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
copied = true;
|
|
}
|
|
} catch {
|
|
// Clipboard API failed, try fallback
|
|
}
|
|
|
|
// Method 2: execCommand fallback (works better on iOS Safari)
|
|
if (!copied) {
|
|
try {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = shareUrl;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.left = '-9999px';
|
|
textarea.style.top = '0';
|
|
textarea.setAttribute('readonly', ''); // Prevent zoom on iOS
|
|
document.body.appendChild(textarea);
|
|
textarea.focus();
|
|
textarea.select();
|
|
// iOS needs setSelectionRange
|
|
textarea.setSelectionRange(0, shareUrl.length);
|
|
copied = document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
} catch {
|
|
// execCommand failed too
|
|
}
|
|
}
|
|
|
|
// Show feedback
|
|
if (copied) {
|
|
const originalText = this.copyLinkBtn.textContent;
|
|
this.copyLinkBtn.textContent = 'Copied!';
|
|
this.shareFallback.style.display = 'none';
|
|
setTimeout(() => {
|
|
this.copyLinkBtn.textContent = originalText;
|
|
}, 1500);
|
|
} else {
|
|
// Show fallback with the link for manual copying
|
|
this.shareFallbackInput.value = shareUrl;
|
|
this.shareFallback.style.display = 'block';
|
|
// Select the text so user can easily copy
|
|
this.shareFallbackInput.focus();
|
|
this.shareFallbackInput.select();
|
|
}
|
|
}
|
|
|
|
private async handleShareToX(): Promise<void> {
|
|
if (!this.hasGenerated) return;
|
|
const result = this.searchResults[this.selectedResultIndex];
|
|
if (!result?.midiUrl) return;
|
|
|
|
const title = this.cleanSongTitle(result.title || 'a song');
|
|
|
|
// Open window immediately (must be in user gesture context for mobile)
|
|
// We'll navigate it after getting the share URL
|
|
const popup = window.open('about:blank', '_blank');
|
|
|
|
// Get share URL (try short link first)
|
|
let shareUrl: string | null = null;
|
|
try {
|
|
let payload: any = null;
|
|
if (result.source === 'bitmidi') {
|
|
const m = String(result.midiUrl).match(/\/uploads\/(\d+)\.mid/i);
|
|
if (m?.[1]) payload = { src: 'bitmidi', id: m[1], title: result.title };
|
|
}
|
|
if (!payload) payload = { u: result.midiUrl, title: result.title };
|
|
|
|
const resp = await fetch('/api/share', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (data?.url) shareUrl = `${window.location.origin}${data.url}`;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
|
|
if (!shareUrl) {
|
|
shareUrl = window.location.href;
|
|
}
|
|
|
|
// Compose tweet text
|
|
const tweetText = `I made a Game Boy version of ${title}\n\nUsing @b1rdmania's Wario Synthesis Midi Engine\n\nCheck it out here`;
|
|
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(shareUrl)}`;
|
|
|
|
// Navigate the already-opened window
|
|
if (popup) {
|
|
popup.location.href = twitterUrl;
|
|
} else {
|
|
// Fallback if popup was blocked
|
|
window.location.href = twitterUrl;
|
|
}
|
|
}
|
|
|
|
private floatToInt16(input: Float32Array): Int16Array {
|
|
const out = new Int16Array(input.length);
|
|
for (let i = 0; i < input.length; i++) {
|
|
const s = Math.max(-1, Math.min(1, input[i]));
|
|
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private getEventsDurationSec(events: NoteEvent[]): number {
|
|
let maxEnd = 0;
|
|
for (const e of events) {
|
|
const end = (e.time || 0) + (e.duration || 0);
|
|
if (end > maxEnd) maxEnd = end;
|
|
}
|
|
return maxEnd;
|
|
}
|
|
|
|
private trimEventsForDuration(events: NoteEvent[], maxSec: number): NoteEvent[] {
|
|
if (!Number.isFinite(maxSec) || maxSec <= 0) return [];
|
|
const out: NoteEvent[] = [];
|
|
for (const e of events) {
|
|
const t = e.time || 0;
|
|
if (t >= maxSec) continue;
|
|
const maxDur = maxSec - t;
|
|
const dur = Math.max(0, Math.min(e.duration || 0, maxDur));
|
|
if (dur <= 0) continue;
|
|
out.push({ ...e, duration: dur });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private clearPreparedMp3(): void {
|
|
if (this.preparedMp3RevokeTimeout !== null) {
|
|
window.clearTimeout(this.preparedMp3RevokeTimeout);
|
|
this.preparedMp3RevokeTimeout = null;
|
|
}
|
|
if (this.preparedMp3) {
|
|
try {
|
|
URL.revokeObjectURL(this.preparedMp3.url);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.preparedMp3 = null;
|
|
}
|
|
if (this.downloadMp3Btn) {
|
|
this.downloadMp3Btn.textContent = this.downloadMp3BtnLabel;
|
|
}
|
|
}
|
|
|
|
private async trySavePreparedMp3(prepared: { url: string; filename: string; blob: Blob }): Promise<void> {
|
|
// Best effort ordering:
|
|
// 1) Share sheet (iOS Safari often can't "download" blob URLs reliably)
|
|
// 2) Open in a new tab (lets user use the browser's share/save UI)
|
|
// 3) Regular anchor download
|
|
const file = new File([prepared.blob], prepared.filename, { type: 'audio/mpeg' });
|
|
|
|
const canShareFiles =
|
|
typeof navigator !== 'undefined' &&
|
|
typeof (navigator as any).share === 'function' &&
|
|
typeof (navigator as any).canShare === 'function' &&
|
|
(navigator as any).canShare({ files: [file] });
|
|
|
|
if (canShareFiles) {
|
|
await (navigator as any).share({
|
|
files: [file],
|
|
title: prepared.filename,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// iOS-like browsers: open blob in a new tab/window (download attr is unreliable)
|
|
if (this.isIOSLike()) {
|
|
const opened = window.open(prepared.url, '_blank', 'noopener,noreferrer');
|
|
if (!opened) {
|
|
// popup blocked: navigate current tab
|
|
window.location.href = prepared.url;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Default: anchor download (must run inside a user gesture)
|
|
const a = document.createElement('a');
|
|
a.href = prepared.url;
|
|
a.download = prepared.filename;
|
|
a.rel = 'noopener';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
private async handleDownloadMp3(): Promise<void> {
|
|
if (!this.hasGenerated || !this.currentMIDI) return;
|
|
|
|
if (this.isMp3Preparing) {
|
|
this.updateStatus('Still preparing MP3…');
|
|
return;
|
|
}
|
|
|
|
// If we already prepared an MP3, do the *actual* download synchronously in the click handler.
|
|
// Many browsers block downloads that occur after an async chain (user activation is lost).
|
|
if (this.preparedMp3) {
|
|
try {
|
|
await this.trySavePreparedMp3(this.preparedMp3);
|
|
this.updateStatus(this.isIOSLike() ? 'Opened MP3. Use Share/Save.' : 'Downloading…');
|
|
window.setTimeout(() => this.updateStatus(''), 1200);
|
|
// Reset after a save attempt so the button doesn't feel “dead”.
|
|
this.clearPreparedMp3();
|
|
} catch (e) {
|
|
this.updateStatus(`Download blocked: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.isMp3Preparing = true;
|
|
this.downloadMp3Btn.disabled = true;
|
|
this.downloadMp3Btn.textContent = 'Preparing MP3…';
|
|
|
|
// Stop any current playback
|
|
this.handleMotifStop();
|
|
|
|
const result = this.searchResults[this.selectedResultIndex];
|
|
const safeTitle = this.cleanSongTitle(result?.title || 'wario-synth').slice(0, 80) || 'wario-synth';
|
|
const filename = `${safeTitle}.mp3`;
|
|
|
|
// Guard against huge exports that feel like a “stall”.
|
|
const durationSec = this.getEventsDurationSec(this.currentMIDI.events);
|
|
const MAX_EXPORT_SEC = 180; // 3 minutes max by default (keeps export fast/reliable)
|
|
let exportSec = durationSec;
|
|
if (durationSec > MAX_EXPORT_SEC) {
|
|
const promptVal = window.prompt(
|
|
`This track is about ${Math.round(durationSec)}s long.\n\nEnter seconds to export (e.g. ${MAX_EXPORT_SEC}), or leave blank for full song:`,
|
|
String(MAX_EXPORT_SEC)
|
|
);
|
|
if (promptVal === null) {
|
|
this.updateStatus('MP3 export cancelled.');
|
|
return;
|
|
}
|
|
const trimmed = promptVal.trim();
|
|
if (trimmed.length === 0) {
|
|
exportSec = durationSec; // full
|
|
} else {
|
|
const n = Number(trimmed);
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
this.updateStatus('Invalid export duration.');
|
|
return;
|
|
}
|
|
exportSec = Math.min(durationSec, n);
|
|
}
|
|
}
|
|
|
|
const exportEvents = this.trimEventsForDuration(this.currentMIDI.events, exportSec);
|
|
if (exportEvents.length === 0) {
|
|
throw new Error('Nothing to export (empty clip)');
|
|
}
|
|
|
|
// Prefer File System Access API when available (single click, no download blocking).
|
|
const picker = (window as any).showSaveFilePicker as
|
|
| undefined
|
|
| ((opts: any) => Promise<any>);
|
|
let writable: any = null;
|
|
|
|
if (typeof picker === 'function') {
|
|
try {
|
|
this.downloadMp3Btn.textContent = 'Choose save location…';
|
|
this.updateStatus('Choose where to save the MP3…');
|
|
const handle = await picker({
|
|
suggestedName: filename,
|
|
types: [
|
|
{
|
|
description: 'MP3 Audio',
|
|
accept: { 'audio/mpeg': ['.mp3'] },
|
|
},
|
|
],
|
|
});
|
|
writable = await handle.createWritable();
|
|
} catch (e: any) {
|
|
if (e?.name === 'AbortError') {
|
|
this.updateStatus('MP3 export cancelled.');
|
|
return;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
this.downloadMp3Btn.textContent = 'Rendering…';
|
|
this.updateStatus('Rendering audio…');
|
|
|
|
// Render offline
|
|
const sampleRate = 44100;
|
|
const audioBuffer = await this.motifEngine.renderOffline(
|
|
exportEvents,
|
|
'procedural',
|
|
sampleRate
|
|
);
|
|
|
|
this.downloadMp3Btn.textContent = 'Encoding… 0%';
|
|
this.updateStatus('Encoding MP3… 0%');
|
|
|
|
const Mp3Encoder = (lamejs as any)?.Mp3Encoder;
|
|
if (!Mp3Encoder) throw new Error('MP3 encoder not loaded');
|
|
|
|
const channels = audioBuffer.numberOfChannels;
|
|
const kbps = 128;
|
|
const encoder = new Mp3Encoder(channels, sampleRate, kbps);
|
|
const mp3Chunks: BlobPart[] = [];
|
|
|
|
// Get audio data
|
|
const leftData = audioBuffer.getChannelData(0);
|
|
const rightData = channels > 1 ? audioBuffer.getChannelData(1) : leftData;
|
|
const left = this.floatToInt16(leftData);
|
|
const right = this.floatToInt16(rightData);
|
|
|
|
// Encode in 1152-sample frames
|
|
const frameSize = 1152;
|
|
const totalFrames = Math.max(1, Math.ceil(left.length / frameSize));
|
|
let framesDone = 0;
|
|
|
|
for (let i = 0; i < left.length; i += frameSize) {
|
|
const l = left.subarray(i, i + frameSize);
|
|
const r = right.subarray(i, i + frameSize);
|
|
const buf = encoder.encodeBuffer(l, r);
|
|
if (buf && buf.length) {
|
|
if (writable) {
|
|
await writable.write(new Uint8Array(buf));
|
|
} else {
|
|
mp3Chunks.push(buf);
|
|
}
|
|
}
|
|
|
|
framesDone++;
|
|
if (framesDone % 64 === 0 || framesDone === totalFrames) {
|
|
const pct = Math.min(99, Math.floor((framesDone / totalFrames) * 100));
|
|
this.downloadMp3Btn.textContent = `Encoding… ${pct}%`;
|
|
this.updateStatus(`Encoding MP3… ${pct}%`);
|
|
// Yield to the UI thread so the page doesn't feel frozen.
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
const tail = encoder.flush();
|
|
if (tail && tail.length) {
|
|
if (writable) {
|
|
await writable.write(new Uint8Array(tail));
|
|
} else {
|
|
mp3Chunks.push(tail);
|
|
}
|
|
}
|
|
|
|
if (writable) {
|
|
this.downloadMp3Btn.textContent = 'Saving…';
|
|
this.updateStatus('Saving MP3…');
|
|
await writable.close();
|
|
this.updateStatus('MP3 saved!');
|
|
window.setTimeout(() => this.updateStatus(''), 1200);
|
|
} else {
|
|
// Fallback: create a Blob URL and require a second click to save (avoids download blocking).
|
|
const blob = new Blob(mp3Chunks, { type: 'audio/mpeg' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
this.preparedMp3 = { url, filename, blob };
|
|
this.downloadMp3Btn.textContent = this.isIOSLike() ? 'Save MP3 (ready)' : 'Download MP3 (ready)';
|
|
this.updateStatus(this.isIOSLike() ? 'MP3 ready. Tap Save MP3 to open/share.' : 'MP3 ready. Click Download MP3 again to save.');
|
|
|
|
// Auto-cleanup to avoid leaking blob URLs.
|
|
this.preparedMp3RevokeTimeout = window.setTimeout(() => {
|
|
this.clearPreparedMp3();
|
|
}, 5 * 60 * 1000);
|
|
}
|
|
} catch (e) {
|
|
this.updateStatus(`MP3 failed: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
this.clearPreparedMp3();
|
|
} finally {
|
|
this.isMp3Preparing = false;
|
|
this.downloadMp3Btn.disabled = false;
|
|
if (!this.preparedMp3) this.downloadMp3Btn.textContent = this.downloadMp3BtnLabel;
|
|
}
|
|
}
|
|
|
|
private setState(state: 'idle' | 'results' | 'selected' | 'generated'): void {
|
|
// results - keep visible in all states except idle so user can pick a different source
|
|
const hasResults = state === 'results' || state === 'selected' || state === 'generated';
|
|
this.resultsSection.classList.toggle('visible', hasResults);
|
|
this.chooseHelper.style.display = 'none';
|
|
|
|
// selected card
|
|
const hasSelection = state === 'selected' || state === 'generated';
|
|
this.playerSection.classList.toggle('visible', hasSelection);
|
|
|
|
// share buttons only after generation
|
|
this.copyLinkBtn.style.display = state === 'generated' ? 'inline-block' : 'none';
|
|
this.copyLinkBtn.disabled = !(state === 'generated' && this.hasGenerated);
|
|
this.shareToXBtn.style.display = state === 'generated' ? 'inline-block' : 'none';
|
|
this.shareToXBtn.disabled = !(state === 'generated' && this.hasGenerated);
|
|
this.downloadMp3Btn.style.display = state === 'generated' ? 'inline-block' : 'none';
|
|
this.downloadMp3Btn.disabled = !(state === 'generated' && this.hasGenerated);
|
|
// Hide share fallback when state changes
|
|
this.shareFallback.style.display = 'none';
|
|
|
|
// New search button only after generation
|
|
if (this.newSearchBtn) {
|
|
this.newSearchBtn.style.display = state === 'generated' ? 'inline-block' : 'none';
|
|
}
|
|
|
|
// Dim results when selected/generated, but keep them interactive
|
|
this.resultsSection.classList.toggle('collapsed', state === 'selected' || state === 'generated');
|
|
|
|
// Never lock search - users should always be able to search or pick different results
|
|
|
|
// Selected pre-generation content
|
|
const showPreGen = state === 'selected';
|
|
this.preGenActions.style.display = showPreGen ? 'block' : 'none';
|
|
this.preGenSupport.style.display = showPreGen ? 'flex' : 'none';
|
|
|
|
// Generated artifact content
|
|
const showGenerated = state === 'generated';
|
|
this.generatedBlock.style.display = showGenerated ? 'block' : 'none';
|
|
this.playPauseBtn.disabled = !showGenerated || !this.hasGenerated;
|
|
this.playPauseBtn.textContent = this.isMotifPlaying ? 'Pause' : 'Play';
|
|
|
|
// iOS banner only before generation
|
|
if (showPreGen) {
|
|
this.updateIOSAudioBanner();
|
|
} else {
|
|
this.iosAudioBanner.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
private async handlePlayPause(): Promise<void> {
|
|
if (!this.hasGenerated) return;
|
|
|
|
if (this.isMotifPlaying) {
|
|
this.motifResumeProgress = this.motifEngine.getProgress();
|
|
this.motifEngine.stop();
|
|
this.isMotifPlaying = false;
|
|
this.stopMotifProgressUpdates();
|
|
this.playPauseBtn.textContent = 'Play';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Audio exclusivity
|
|
this.stopPreview();
|
|
await unlockAudio();
|
|
|
|
this.motifEngine.setVolume(0.8);
|
|
await this.motifEngine.play();
|
|
if (this.motifResumeProgress > 0) this.motifEngine.seek(this.motifResumeProgress);
|
|
this.isMotifPlaying = true;
|
|
this.playPauseBtn.textContent = 'Pause';
|
|
this.startMotifProgressUpdates();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
private updateEmbedSnippet(songTitle: string): void {
|
|
if (!this.embedSection || !this.embedCodeEl) return;
|
|
|
|
const notLive = this.embedSection.getAttribute('data-not-live') === 'true';
|
|
const base = notLive ? 'https://YOUR_DOMAIN' : window.location.origin;
|
|
const url = `${base}/embed?song=${encodeURIComponent(songTitle)}`;
|
|
|
|
const snippet = `<iframe\n src=\"${url}\"\n width=\"420\"\n height=\"260\"\n style=\"border:0;border-radius:12px;overflow:hidden\"\n allow=\"autoplay\"\n></iframe>`;
|
|
|
|
this.embedCodeEl.textContent = snippet;
|
|
this.embedSection.style.display = 'block';
|
|
if (this.copyToast) this.copyToast.style.display = 'none';
|
|
}
|
|
|
|
private async copyEmbedSnippet(): Promise<void> {
|
|
if (!this.embedCodeEl) return;
|
|
if (this.embedSection?.getAttribute('data-not-live') === 'true') {
|
|
this.updateStatus('Embed is coming soon.');
|
|
return;
|
|
}
|
|
|
|
const text = this.embedCodeEl.textContent || '';
|
|
if (!text.trim()) return;
|
|
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
// Fallback
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.left = '-9999px';
|
|
document.body.appendChild(ta);
|
|
ta.focus();
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
if (this.copyToast) {
|
|
this.copyToast.style.display = 'inline';
|
|
window.setTimeout(() => {
|
|
if (this.copyToast) this.copyToast.style.display = 'none';
|
|
}, 1200);
|
|
}
|
|
} catch {
|
|
this.updateStatus('Copy failed. Select the snippet and copy manually.');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Make app globally available for onclick handlers
|
|
try {
|
|
const app = new MotifApp();
|
|
(window as any).app = app;
|
|
console.log('[MotifApp] Initialized successfully');
|
|
} catch (err) {
|
|
console.error('[MotifApp] Failed to initialize:', err);
|
|
// Show error in UI so users can report it
|
|
const status = document.getElementById('status');
|
|
if (status) {
|
|
status.textContent = `App error: ${err instanceof Error ? err.message : 'Unknown error'}. Please refresh.`;
|
|
status.style.color = '#ff6b6b';
|
|
}
|
|
} |