Files
motif/src/main.ts
T
b1rdmania 1eef11acbe Fix iOS clipboard with contentEditable approach
- Try modern clipboard API first
- For iOS: use contentEditable + special range selection
- Set font-size 16px to prevent zoom on iOS
- Properly throw error on failure for user feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:53:55 +00:00

935 lines
32 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';
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 selectedDetails!: HTMLElement;
private selectedExpectation!: HTMLElement;
private preGenActions!: HTMLElement;
private preGenSupport!: HTMLElement;
private generatedBlock!: HTMLElement;
private playPauseBtn!: HTMLButtonElement;
private detailsToggle!: HTMLElement;
private previewBtn!: HTMLButtonElement;
private previewStopBtn!: HTMLButtonElement;
private previewState!: HTMLElement;
private chooseDifferentBtn!: HTMLButtonElement;
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 copyLinkConfirm!: HTMLElement;
// 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 faqBtn!: HTMLButtonElement;
private faqBtnFooter: 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.selectedDetails = document.getElementById('selectedDetails')!;
this.selectedExpectation = document.getElementById('selectedExpectation')!;
this.preGenActions = document.getElementById('preGenActions')!;
this.preGenSupport = document.getElementById('preGenSupport')!;
this.generatedBlock = document.getElementById('generatedBlock')!;
this.playPauseBtn = document.getElementById('playPauseBtn') as HTMLButtonElement;
this.detailsToggle = document.getElementById('detailsToggle')!;
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;
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.copyLinkConfirm = document.getElementById('copyLinkConfirm') as HTMLElement;
// 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.faqBtn = document.getElementById('faqBtn') as HTMLButtonElement;
this.faqBtnFooter = document.getElementById('faqBtnFooter') 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 {
this.searchBtn.addEventListener('click', () => this.handleSearch());
this.songInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleSearch();
}
});
// 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());
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;
this.handleMotifSeek(progress);
};
this.motifProgressBar.addEventListener('input', seekHandler);
this.motifProgressBar.addEventListener('change', seekHandler);
this.copyLinkBtn.addEventListener('click', () => void this.handleCopyLink());
// 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.faqBtn.addEventListener('click', () => this.openFaq());
this.faqBtnFooter?.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.hasGenerated = false;
this.isMotifPlaying = false;
this.motifResumeProgress = 0;
this.currentMIDI = null;
this.setState('idle');
this.updateStatus('Ready. Enter a song name to search for MIDI files.');
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> {
const songName = this.songInput.value.trim();
if (!songName) return;
// Stop any playing Motif audio
this.handleMotifStop();
this.updateStatus('Searching…');
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 source to use.');
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="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-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.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)} · Length: ${this.formatDuration(actualDuration)} · Notes: ${events.length}`;
// Optional details (hidden behind toggle)
const detailParts: string[] = [];
const trackCount = Number(metadata.trackCount);
const tempo = Number(metadata.tempo);
const duration = Number(actualDuration);
if (Number.isFinite(trackCount) && trackCount > 0) detailParts.push(`Tracks: ${trackCount}`);
if (Number.isFinite(tempo) && tempo > 0) detailParts.push(`Tempo: ${Math.round(tempo)} bpm`);
if (Number.isFinite(duration) && duration > 0) detailParts.push(`Length: ${this.formatDuration(duration)}`);
detailParts.push(`Notes: ${events.length}`);
this.selectedDetails.textContent = detailParts.join(' · ');
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();
// Best-effort: ensure iOS audio is unlocked from this user gesture.
await unlockAudio();
this.updateIOSAudioBanner();
// Once generation starts: lock into generated experience mode.
this.setState('generated');
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.)
this.setState('generated');
} 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();
return this.titleCase(cleaned || 'Unknown');
}
private formatDuration(secondsMaybe: any): string {
const seconds = Number(secondsMaybe);
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
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 cleanTitleForShare(title: string): string {
return (title || '')
.replace(/\.mid$/i, '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200);
}
private async copyToClipboard(text: string): Promise<void> {
// Try modern clipboard API first
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall through to textarea method
}
}
// Fallback for iOS Safari and older browsers
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '0';
ta.style.left = '0';
ta.style.width = '2em';
ta.style.height = '2em';
ta.style.padding = '0';
ta.style.border = 'none';
ta.style.outline = 'none';
ta.style.boxShadow = 'none';
ta.style.background = 'transparent';
ta.style.fontSize = '16px'; // Prevent zoom on iOS
ta.setAttribute('readonly', '');
document.body.appendChild(ta);
// iOS needs special handling
const isiOS = /ipad|iphone|ipod/i.test(navigator.userAgent);
if (isiOS) {
ta.contentEditable = 'true';
ta.readOnly = false;
const range = document.createRange();
range.selectNodeContents(ta);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
ta.setSelectionRange(0, 999999);
} else {
ta.select();
}
let success = false;
try {
success = document.execCommand('copy');
} catch {
// ignore
}
document.body.removeChild(ta);
if (!success) {
throw new Error('Copy failed');
}
}
private async handleCopyLink(): Promise<void> {
if (!this.hasGenerated) return;
const result = this.searchResults[this.selectedResultIndex];
if (!result?.midiUrl) return;
const title = this.cleanTitleForShare(result.title || 'MOTIF');
// Prefer backend shortlink /s/<code>
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
}
// Fallback: direct /play link (still works, just longer)
if (!shareUrl) {
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])}`;
}
}
if (!shareUrl) {
shareUrl = `${window.location.origin}/play?u=${encodeURIComponent(result.midiUrl)}`;
}
}
try {
this.copyLinkBtn.disabled = true;
await this.copyToClipboard(shareUrl);
// Show confirmation
this.copyLinkConfirm.style.display = 'inline';
this.copyLinkConfirm.textContent = 'Link copied!';
window.setTimeout(() => {
this.copyLinkConfirm.style.display = 'none';
}, 3000);
} catch (err) {
// Show error feedback
this.copyLinkConfirm.style.display = 'inline';
this.copyLinkConfirm.textContent = 'Copy failed';
window.setTimeout(() => {
this.copyLinkConfirm.style.display = 'none';
}, 3000);
} finally {
this.copyLinkBtn.disabled = false;
}
}
private setState(state: 'idle' | 'results' | 'selected' | 'generated'): void {
// results
const hasResults = state === 'results' || state === 'selected';
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' && this.hasGenerated);
// 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.selectedExpectation.style.display = showPreGen ? 'block' : 'none';
this.preGenActions.style.display = showPreGen ? 'block' : 'none';
this.preGenSupport.style.display = showPreGen ? 'flex' : 'none';
this.detailsToggle.style.display = showPreGen ? 'block' : '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 goToResults(): void {
this.setState('results');
try {
this.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
// ignore
}
}
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
const app = new MotifApp();
(window as any).app = app;