Add /play share page and X share links.
Includes a UX test page and Vercel/Vite routing for share URLs.
This commit is contained in:
@@ -0,0 +1,197 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
|
<title>MOTIF - Play</title>
|
||||||
|
<meta name="description" content="Play a shared MOTIF chiptune and generate your own." />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-bg: #0a0a0a;
|
||||||
|
--color-surface: #141414;
|
||||||
|
--color-surface-elevated: #1a1a1a;
|
||||||
|
--color-border: #2a2a2a;
|
||||||
|
--color-text: #e8e8e8;
|
||||||
|
--color-text-dim: #999;
|
||||||
|
--color-accent-primary: #00ff88;
|
||||||
|
--color-accent-secondary: #ffaa44;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 22px 16px;
|
||||||
|
background:
|
||||||
|
radial-gradient(1000px 600px at 50% -150px, rgba(0, 255, 136, 0.12), transparent 60%),
|
||||||
|
radial-gradient(800px 600px at 110% 20%, rgba(255, 170, 68, 0.08), transparent 55%),
|
||||||
|
var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
text-shadow: 0 0 20px rgba(0, 255, 136, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(20, 20, 20, 0.92);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, a.button {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--color-accent-secondary);
|
||||||
|
border-color: rgba(255, 170, 68, 0.6);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled), a.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
min-width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: rgba(0, 255, 136, 0.85);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<div class="brand">MOTIF</div>
|
||||||
|
<div class="sub">Shared chiptune player</div>
|
||||||
|
</div>
|
||||||
|
<div class="sub" id="shareHint">/play</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1 id="title">Loading…</h1>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="playBtn" class="primary" disabled>Play</button>
|
||||||
|
<button id="stopBtn" disabled>Stop</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="volume">Volume</label>
|
||||||
|
<input id="volume" type="range" min="0" max="1" step="0.01" value="0.35" />
|
||||||
|
</div>
|
||||||
|
<div id="status"></div>
|
||||||
|
<footer>
|
||||||
|
<a id="generateOwn" href="/">Generate your own</a>
|
||||||
|
<a href="/" aria-label="Back to MOTIF home">Home</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/play.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -27,7 +27,10 @@ export class MotifEngine {
|
|||||||
this.roleMapper = new RoleMapper();
|
this.roleMapper = new RoleMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise<void> {
|
async generateFromMIDI(
|
||||||
|
events: NoteEvent[],
|
||||||
|
transformMode: 'passthrough' | 'procedural' = 'passthrough'
|
||||||
|
): Promise<void> {
|
||||||
// Initialize audio context using shared unlock (iOS compatibility)
|
// Initialize audio context using shared unlock (iOS compatibility)
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = await unlockAudio();
|
this.audioContext = await unlockAudio();
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
import { MIDIService } from './services/MIDIService';
|
||||||
|
import { MIDIParser } from './midi/MIDIParser';
|
||||||
|
import { MotifEngine } from './core/MotifEngine';
|
||||||
|
import type { NoteEvent } from './types';
|
||||||
|
|
||||||
|
function qs(id: string): HTMLElement {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) throw new Error(`Missing element #${id}`);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParam(name: string): string | null {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return url.searchParams.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMidiUrlFromParams(): { midiUrl: string | null; title: string } {
|
||||||
|
const u = getParam('u');
|
||||||
|
const src = getParam('src');
|
||||||
|
const id = getParam('id');
|
||||||
|
const title = getParam('title') || 'Shared MOTIF';
|
||||||
|
|
||||||
|
if (u) return { midiUrl: u, title };
|
||||||
|
|
||||||
|
// Short-link form (no server storage): reconstruct known providers.
|
||||||
|
if (src === 'bitmidi' && id && /^\d+$/.test(id)) {
|
||||||
|
return { midiUrl: `https://bitmidi.com/uploads/${id}.mid`, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { midiUrl: null, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
ta.style.top = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const titleEl = qs('title');
|
||||||
|
const statusEl = qs('status');
|
||||||
|
const playBtn = qs('playBtn') as HTMLButtonElement;
|
||||||
|
const stopBtn = qs('stopBtn') as HTMLButtonElement;
|
||||||
|
const volumeEl = qs('volume') as HTMLInputElement;
|
||||||
|
const generateOwn = qs('generateOwn') as HTMLAnchorElement;
|
||||||
|
const shareHint = qs('shareHint');
|
||||||
|
|
||||||
|
const { midiUrl: u, title } = buildMidiUrlFromParams();
|
||||||
|
|
||||||
|
titleEl.textContent = title;
|
||||||
|
shareHint.textContent = u ? 'Ready' : 'Missing MIDI URL';
|
||||||
|
|
||||||
|
// Set CTA back to home, prefilling search if we have a title.
|
||||||
|
generateOwn.href = title ? `/?song=${encodeURIComponent(title)}` : '/';
|
||||||
|
|
||||||
|
if (!u) {
|
||||||
|
statusEl.textContent = 'Missing link data (u=...).';
|
||||||
|
playBtn.disabled = true;
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const midiService = new MIDIService();
|
||||||
|
const motifEngine = new MotifEngine();
|
||||||
|
let events: NoteEvent[] | null = null;
|
||||||
|
let isPlaying = false;
|
||||||
|
|
||||||
|
statusEl.textContent = 'Loading MIDI…';
|
||||||
|
try {
|
||||||
|
const buf = await midiService.fetchMIDI(u);
|
||||||
|
if (!buf) throw new Error('Failed to fetch MIDI');
|
||||||
|
events = MIDIParser.parseMIDI(buf);
|
||||||
|
statusEl.textContent = 'Ready. Tap Play.';
|
||||||
|
playBtn.disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = `Load error: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||||
|
playBtn.disabled = true;
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUiPlaying(playing: boolean): void {
|
||||||
|
isPlaying = playing;
|
||||||
|
playBtn.disabled = playing;
|
||||||
|
stopBtn.disabled = !playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
volumeEl.addEventListener('input', () => {
|
||||||
|
const vol = Number.parseFloat(volumeEl.value);
|
||||||
|
motifEngine.setVolume(vol);
|
||||||
|
});
|
||||||
|
|
||||||
|
stopBtn.addEventListener('click', () => {
|
||||||
|
motifEngine.stop();
|
||||||
|
setUiPlaying(false);
|
||||||
|
statusEl.textContent = 'Stopped.';
|
||||||
|
});
|
||||||
|
|
||||||
|
playBtn.addEventListener('click', async () => {
|
||||||
|
if (!events || isPlaying) return;
|
||||||
|
try {
|
||||||
|
setUiPlaying(true);
|
||||||
|
statusEl.textContent = 'Generating…';
|
||||||
|
|
||||||
|
// Match main page output: passthrough → existing SynthesisEngine.
|
||||||
|
await motifEngine.generateFromMIDI(events, 'passthrough');
|
||||||
|
motifEngine.setVolume(Number.parseFloat(volumeEl.value));
|
||||||
|
|
||||||
|
statusEl.textContent = 'Playing…';
|
||||||
|
await motifEngine.play();
|
||||||
|
} catch (e) {
|
||||||
|
setUiPlaying(false);
|
||||||
|
statusEl.textContent = `Play error: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small quality-of-life: let user click the header “Ready” to copy link.
|
||||||
|
shareHint.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(window.location.href);
|
||||||
|
shareHint.textContent = 'Link copied';
|
||||||
|
window.setTimeout(() => (shareHint.textContent = 'Ready'), 900);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
shareHint.style.cursor = 'pointer';
|
||||||
|
shareHint.title = 'Click to copy link';
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
|
|
||||||
+753
@@ -0,0 +1,753 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MOTIF - UX Test</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #08080c;
|
||||||
|
--surface: #101014;
|
||||||
|
--surface-2: #18181f;
|
||||||
|
--border: rgba(255,255,255,0.08);
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--text-dim: #888;
|
||||||
|
--neon-green: #00ff88;
|
||||||
|
--neon-pink: #ff00aa;
|
||||||
|
--neon-blue: #00aaff;
|
||||||
|
--neon-orange: #ff8800;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--neon-green);
|
||||||
|
text-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--neon-green);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]::placeholder {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255,255,255,0.2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--neon-green);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--neon-green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 0 24px rgba(0, 255, 136, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.status {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: var(--neon-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
.results {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
border-color: rgba(255,255,255,0.15);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.selected {
|
||||||
|
border-color: var(--neon-green);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 136, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player Card */
|
||||||
|
.player-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EQ Visualizer */
|
||||||
|
.eq-visualizer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 80px;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-visualizer::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0,0,0,0.1) 2px,
|
||||||
|
rgba(0,0,0,0.1) 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-bar {
|
||||||
|
width: 6px;
|
||||||
|
min-height: 3px;
|
||||||
|
background: linear-gradient(to top, var(--neon-green) 0%, var(--neon-blue) 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: height 0.05s ease-out;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 255, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-bar.hot {
|
||||||
|
background: linear-gradient(to top, var(--neon-orange) 0%, var(--neon-pink) 100%);
|
||||||
|
box-shadow: 0 0 8px rgba(255, 0, 170, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player Controls */
|
||||||
|
.player-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls button {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Share row */
|
||||||
|
.share-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.share-row button {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
.share-hint {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume */
|
||||||
|
.volume-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--neon-green);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Toggle */
|
||||||
|
.mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--neon-green);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: var(--neon-green);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🎵 MOTIF</h1>
|
||||||
|
<p class="tagline">Gameboy synth for any song</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="songInput" placeholder="Search any song..." value="Hotel California" />
|
||||||
|
<button id="searchBtn">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status">Search for a song to get started</div>
|
||||||
|
|
||||||
|
<div id="results" class="results">
|
||||||
|
<div class="results-label">Results</div>
|
||||||
|
<div id="resultsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="playerCard" class="player-card">
|
||||||
|
<div class="now-playing">
|
||||||
|
<div class="now-playing-label">Now Playing</div>
|
||||||
|
<div class="now-playing-title" id="nowPlayingTitle">--</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="eq-visualizer" id="eqVisualizer"></div>
|
||||||
|
|
||||||
|
<div class="mode-toggle">
|
||||||
|
<button class="mode-btn active" data-mode="preview">Preview</button>
|
||||||
|
<button class="mode-btn" data-mode="synth">Synth Engine</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-controls">
|
||||||
|
<button id="playBtn" class="primary">Play</button>
|
||||||
|
<button id="stopBtn" disabled>Stop</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="share-row">
|
||||||
|
<button id="copyLinkBtn" disabled>Copy link</button>
|
||||||
|
<button id="shareXBtn" disabled>Share on X</button>
|
||||||
|
</div>
|
||||||
|
<div id="shareHint" class="share-hint"></div>
|
||||||
|
|
||||||
|
<div class="volume-row">
|
||||||
|
<span class="volume-label">Volume</span>
|
||||||
|
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="0.7" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>UX Test Page · <a href="/">Back to main</a></p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { MIDIService } from './src/services/MIDIService.ts';
|
||||||
|
import { MIDIParser } from './src/midi/MIDIParser.ts';
|
||||||
|
import { SoundfontMIDIPlayer } from './src/synthesis/SoundfontMIDIPlayer.ts';
|
||||||
|
import { MotifEngine } from './src/core/MotifEngine.ts';
|
||||||
|
import { unlockAudio } from './src/utils/audioUnlock.ts';
|
||||||
|
|
||||||
|
const NUM_BARS = 40;
|
||||||
|
const midiService = new MIDIService();
|
||||||
|
|
||||||
|
let previewPlayer = null;
|
||||||
|
let motifEngine = null;
|
||||||
|
let analyser = null;
|
||||||
|
let dataArray = null;
|
||||||
|
let animationId = null;
|
||||||
|
let currentEvents = null;
|
||||||
|
let currentTitle = '';
|
||||||
|
let currentMidiUrl = '';
|
||||||
|
let currentSource = '';
|
||||||
|
let currentMode = 'preview';
|
||||||
|
let isPlaying = false;
|
||||||
|
|
||||||
|
// DOM
|
||||||
|
const eqVisualizer = document.getElementById('eqVisualizer');
|
||||||
|
const nowPlayingTitle = document.getElementById('nowPlayingTitle');
|
||||||
|
const playBtn = document.getElementById('playBtn');
|
||||||
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
|
const copyLinkBtn = document.getElementById('copyLinkBtn');
|
||||||
|
const shareXBtn = document.getElementById('shareXBtn');
|
||||||
|
const shareHint = document.getElementById('shareHint');
|
||||||
|
const searchBtn = document.getElementById('searchBtn');
|
||||||
|
const songInput = document.getElementById('songInput');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const results = document.getElementById('results');
|
||||||
|
const resultsList = document.getElementById('resultsList');
|
||||||
|
const playerCard = document.getElementById('playerCard');
|
||||||
|
const volumeSlider = document.getElementById('volumeSlider');
|
||||||
|
const modeBtns = document.querySelectorAll('.mode-btn');
|
||||||
|
|
||||||
|
// Create EQ bars
|
||||||
|
function createBars() {
|
||||||
|
eqVisualizer.innerHTML = '';
|
||||||
|
for (let i = 0; i < NUM_BARS; i++) {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'eq-bar';
|
||||||
|
bar.style.height = '3px';
|
||||||
|
eqVisualizer.appendChild(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update visualizer
|
||||||
|
function updateVisualizer() {
|
||||||
|
const bars = eqVisualizer.querySelectorAll('.eq-bar');
|
||||||
|
|
||||||
|
// If we don't have a real analyser wired up, run a lightweight faux animation
|
||||||
|
// so the UX test page doesn't depend on player internals.
|
||||||
|
if (!analyser || !dataArray) {
|
||||||
|
bars.forEach((bar) => {
|
||||||
|
const value = isPlaying ? Math.floor(Math.random() * 255) : 0;
|
||||||
|
const height = Math.max(3, (value / 255) * 60);
|
||||||
|
bar.style.height = height + 'px';
|
||||||
|
if (value > 180) bar.classList.add('hot');
|
||||||
|
else bar.classList.remove('hot');
|
||||||
|
});
|
||||||
|
if (isPlaying) animationId = requestAnimationFrame(updateVisualizer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
const step = Math.floor(dataArray.length / NUM_BARS);
|
||||||
|
|
||||||
|
bars.forEach((bar, i) => {
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = 0; j < step; j++) {
|
||||||
|
sum += dataArray[i * step + j] || 0;
|
||||||
|
}
|
||||||
|
const value = sum / step;
|
||||||
|
const height = Math.max(3, (value / 255) * 60);
|
||||||
|
bar.style.height = height + 'px';
|
||||||
|
|
||||||
|
if (value > 180) {
|
||||||
|
bar.classList.add('hot');
|
||||||
|
} else {
|
||||||
|
bar.classList.remove('hot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPlaying) animationId = requestAnimationFrame(updateVisualizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVisualizer() {
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
animationId = null;
|
||||||
|
}
|
||||||
|
const bars = eqVisualizer.querySelectorAll('.eq-bar');
|
||||||
|
bars.forEach(bar => {
|
||||||
|
bar.style.height = '3px';
|
||||||
|
bar.classList.remove('hot');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
async function handleSearch() {
|
||||||
|
const query = songInput.value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
setStatus('Searching...');
|
||||||
|
searchBtn.disabled = true;
|
||||||
|
results.classList.remove('visible');
|
||||||
|
playerCard.classList.remove('visible');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResults = await midiService.search(query);
|
||||||
|
if (searchResults.length === 0) {
|
||||||
|
setStatus('No results found', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayResults(searchResults.slice(0, 5));
|
||||||
|
setStatus(`Found ${searchResults.length} results`);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Error: ${e.message}`, true);
|
||||||
|
} finally {
|
||||||
|
searchBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResults(items) {
|
||||||
|
resultsList.innerHTML = '';
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'result-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<span class="result-title">${item.title}</span>
|
||||||
|
<span class="result-meta">${item.source}</span>
|
||||||
|
`;
|
||||||
|
div.addEventListener('click', () => selectResult(item, div));
|
||||||
|
resultsList.appendChild(div);
|
||||||
|
});
|
||||||
|
results.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectResult(item, element) {
|
||||||
|
// Update selection UI
|
||||||
|
document.querySelectorAll('.result-item').forEach(el => el.classList.remove('selected'));
|
||||||
|
element.classList.add('selected');
|
||||||
|
|
||||||
|
setStatus(`Loading ${item.title}...`);
|
||||||
|
stopPlayback();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const midiBuffer = await midiService.fetchMIDI(item.midiUrl);
|
||||||
|
if (!midiBuffer) throw new Error('Failed to fetch');
|
||||||
|
|
||||||
|
currentEvents = MIDIParser.parseMIDI(midiBuffer);
|
||||||
|
currentTitle = item.title;
|
||||||
|
currentMidiUrl = item.midiUrl;
|
||||||
|
currentSource = item.source;
|
||||||
|
|
||||||
|
// Setup audio
|
||||||
|
const audioContext = await unlockAudio();
|
||||||
|
|
||||||
|
// Create preview player
|
||||||
|
previewPlayer = new SoundfontMIDIPlayer(audioContext);
|
||||||
|
await previewPlayer.load(currentEvents);
|
||||||
|
previewPlayer.setVolume(parseFloat(volumeSlider.value));
|
||||||
|
|
||||||
|
// Create motif engine
|
||||||
|
motifEngine = new MotifEngine();
|
||||||
|
|
||||||
|
// Setup analyser
|
||||||
|
// Note: SoundfontMIDIPlayer doesn't expose a stable analyser hook.
|
||||||
|
// Keep analyser null and use faux EQ animation for the test page.
|
||||||
|
analyser = null;
|
||||||
|
dataArray = null;
|
||||||
|
|
||||||
|
// Show player
|
||||||
|
nowPlayingTitle.textContent = currentTitle;
|
||||||
|
playerCard.classList.add('visible');
|
||||||
|
playBtn.disabled = false;
|
||||||
|
copyLinkBtn.disabled = false;
|
||||||
|
shareXBtn.disabled = false;
|
||||||
|
setStatus('Ready to play');
|
||||||
|
shareHint.textContent = '';
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Error: ${e.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePlay() {
|
||||||
|
if (!currentEvents) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isPlaying = true;
|
||||||
|
playBtn.disabled = true;
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
|
||||||
|
if (currentMode === 'preview') {
|
||||||
|
await previewPlayer.play();
|
||||||
|
} else {
|
||||||
|
// Match main page output: passthrough → SynthesisEngine
|
||||||
|
await motifEngine.generateFromMIDI(currentEvents, 'passthrough');
|
||||||
|
motifEngine.setVolume(parseFloat(volumeSlider.value));
|
||||||
|
await motifEngine.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVisualizer();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Play error: ${e.message}`, true);
|
||||||
|
stopPlayback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPlayback() {
|
||||||
|
isPlaying = false;
|
||||||
|
if (previewPlayer) previewPlayer.stop();
|
||||||
|
if (motifEngine) motifEngine.stop();
|
||||||
|
playBtn.disabled = !currentEvents;
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
stopVisualizer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyShareLink() {
|
||||||
|
if (!currentMidiUrl) return;
|
||||||
|
const url = buildShareUrl();
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = url;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
shareHint.textContent = 'Link copied.';
|
||||||
|
window.setTimeout(() => (shareHint.textContent = ''), 1200);
|
||||||
|
} catch {
|
||||||
|
shareHint.textContent = 'Copy failed.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTitleForShare(title) {
|
||||||
|
return (title || '')
|
||||||
|
.replace(/\.mid$/i, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareUrl() {
|
||||||
|
const title = cleanTitleForShare(currentTitle);
|
||||||
|
|
||||||
|
// Make BitMidi links short: /play?src=bitmidi&id=12345&title=...
|
||||||
|
if (currentSource === 'bitmidi') {
|
||||||
|
const m = String(currentMidiUrl).match(/\/uploads\/(\d+)\.mid/i);
|
||||||
|
if (m && m[1]) {
|
||||||
|
return `${window.location.origin}/play?src=bitmidi&id=${encodeURIComponent(m[1])}&title=${encodeURIComponent(title)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: include full MIDI URL (long, but works)
|
||||||
|
return `${window.location.origin}/play?u=${encodeURIComponent(currentMidiUrl)}&title=${encodeURIComponent(title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareOnX() {
|
||||||
|
if (!currentMidiUrl) return;
|
||||||
|
const url = buildShareUrl();
|
||||||
|
const niceTitle = cleanTitleForShare(currentTitle);
|
||||||
|
// Keep tweet text clean; URL is passed separately.
|
||||||
|
const text = niceTitle ? `MOTIF chiptune: ${niceTitle}` : 'MOTIF chiptune';
|
||||||
|
const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
|
||||||
|
window.open(intent, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, isError = false) {
|
||||||
|
status.textContent = msg;
|
||||||
|
status.classList.toggle('error', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode toggle
|
||||||
|
modeBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
modeBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentMode = btn.dataset.mode;
|
||||||
|
stopPlayback();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Volume
|
||||||
|
volumeSlider.addEventListener('input', () => {
|
||||||
|
const vol = parseFloat(volumeSlider.value);
|
||||||
|
if (previewPlayer) previewPlayer.setVolume(vol);
|
||||||
|
if (motifEngine) motifEngine.setVolume(vol);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events
|
||||||
|
searchBtn.addEventListener('click', handleSearch);
|
||||||
|
songInput.addEventListener('keypress', e => {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
|
});
|
||||||
|
playBtn.addEventListener('click', handlePlay);
|
||||||
|
stopBtn.addEventListener('click', stopPlayback);
|
||||||
|
copyLinkBtn.addEventListener('click', () => void copyShareLink());
|
||||||
|
shareXBtn.addEventListener('click', shareOnX);
|
||||||
|
|
||||||
|
// Init
|
||||||
|
createBars();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -30,6 +30,14 @@
|
|||||||
"src": "/models",
|
"src": "/models",
|
||||||
"dest": "/models.html"
|
"dest": "/models.html"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"src": "/play",
|
||||||
|
"dest": "/play.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/ux-test",
|
||||||
|
"dest": "/ux-test.html"
|
||||||
|
},
|
||||||
{ "handle": "filesystem" },
|
{ "handle": "filesystem" },
|
||||||
{
|
{
|
||||||
"src": "/(.*)",
|
"src": "/(.*)",
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
main: resolve(__dirname, 'index.html'),
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
// Existing pages (keep for backward compatibility)
|
||||||
embed: resolve(__dirname, 'embed.html'),
|
embed: resolve(__dirname, 'embed.html'),
|
||||||
models: resolve(__dirname, 'models.html'),
|
models: resolve(__dirname, 'models.html'),
|
||||||
|
// Sharing trial pages
|
||||||
|
play: resolve(__dirname, 'play.html'),
|
||||||
|
ux_test: resolve(__dirname, 'ux-test.html'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user