Rewrite share player page UX.
Makes /play a narrative artifact page with a clean song title, one-line transformation hook, single Play/Pause control, progress bar, minimal metadata, and a single CTA.
This commit is contained in:
@@ -106,16 +106,7 @@
|
|||||||
background: linear-gradient(90deg, transparent, var(--color-border), transparent);
|
background: linear-gradient(90deg, transparent, var(--color-border), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
#status {
|
/* Share page: no status box */
|
||||||
margin: calc(var(--spacing-unit) * 2) 0 0 0;
|
|
||||||
padding: calc(var(--spacing-unit) * 2);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-left: 3px solid var(--color-accent-primary);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
font-size: 14px;
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
@@ -225,30 +216,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
/* Share page: single CTA only */
|
||||||
margin-top: calc(var(--spacing-unit) * 2);
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions a {
|
|
||||||
color: rgba(0, 255, 136, 0.85);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backpill {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress (matches main page style) */
|
/* Progress (matches main page style) */
|
||||||
.progress-container {
|
.progress-container {
|
||||||
@@ -343,50 +311,59 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="page-title">MOTIF</h1>
|
<div class="motif-stage" style="border-color: rgba(255, 255, 255, 0.08); box-shadow: var(--shadow-lg);">
|
||||||
<p class="tagline">Shared chiptune player</p>
|
<div class="motif-grid" style="gap: 14px;">
|
||||||
|
<!-- 1. Source Song Title -->
|
||||||
|
<h1 id="songTitle" style="margin:0; font-size: 28px; line-height: 1.15; font-weight: 800; color: var(--color-text); letter-spacing: 0.01em;">
|
||||||
|
Loading…
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div class="section">
|
<!-- 2. Transformation Description -->
|
||||||
<div class="section-header">
|
<p id="transformLine" style="margin:0; color: rgba(255,255,255,0.72); font-size: 14px;">
|
||||||
<h3 class="section-title">Wario synthesis engine</h3>
|
Re-synthesised as NES-era chiptune using Motif
|
||||||
<p class="section-subtitle">The same Game Boy/NES render you hear on the main page.</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<div class="motif-stage">
|
<!-- 3. Single Play/Pause Control -->
|
||||||
<div class="motif-grid">
|
<div style="display:flex; justify-content:center; margin-top: 6px;">
|
||||||
<div class="motif-header">
|
<button id="playToggleBtn" class="primary" disabled style="min-width: 220px; padding: 14px 18px; font-size: 15px;">
|
||||||
<h2 class="motif-title" id="title">Loading…</h2>
|
Play
|
||||||
<p class="motif-kicker">Tap play to run the shared MIDI through the Wario engine.</p>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="motif-controls">
|
<!-- 4. Progress Bar -->
|
||||||
<button id="playBtn" class="primary" disabled>Play</button>
|
<div class="progress-container" id="playProgressContainer" style="display:block; margin-top: 0; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
|
||||||
<button id="stopBtn" disabled>Stop</button>
|
|
||||||
<div class="volume-control">
|
|
||||||
<label for="volume">Volume</label>
|
|
||||||
<input id="volume" type="range" min="0" max="1" step="0.01" value="0.7" />
|
|
||||||
</div>
|
|
||||||
<span class="backpill" id="shareHint">/play</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status">Loading…</div>
|
|
||||||
|
|
||||||
<div class="progress-container" id="playProgressContainer">
|
|
||||||
<div class="progress-time">
|
<div class="progress-time">
|
||||||
<span id="playCurrentTime">0:00</span>
|
<span id="playCurrentTime">0:00</span>
|
||||||
<span id="playDuration">0:00</span>
|
<span id="playDuration">0:00</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar-wrapper">
|
<div class="progress-bar-wrapper" style="padding: 10px 0;">
|
||||||
<input id="playProgressBar" class="progress-bar" type="range" min="0" max="100" value="0" step="0.1" />
|
<input id="playProgressBar" class="progress-bar" type="range" min="0" max="100" value="0" step="0.1" />
|
||||||
<div id="playProgressFill" class="progress-fill"></div>
|
<div id="playProgressFill" class="progress-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<!-- 5. Status Text Replacement (static, non-interactive) -->
|
||||||
<a id="generateOwn" href="/">Generate your own</a>
|
<div style="margin-top: 2px; font-size: 13px; color: rgba(255,255,255,0.72);">
|
||||||
<a href="/" aria-label="Back to MOTIF home">Home</a>
|
Procedural playback · No samples · Deterministic
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Minimal metadata line (secondary, low contrast) -->
|
||||||
|
<div style="font-size: 12px; color: rgba(255,255,255,0.52); display:flex; gap: 14px; flex-wrap: wrap;">
|
||||||
|
<span>Engine: Wario (Game Boy / NES)</span>
|
||||||
|
<span>Source: MIDI structure</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Optional volume (acceptable) -->
|
||||||
|
<div class="volume-control" style="min-width: 0; margin-top: 4px; opacity: 0.85;">
|
||||||
|
<label for="volume" style="min-width: auto;">Volume</label>
|
||||||
|
<input id="volume" type="range" min="0" max="1" step="0.01" value="0.7" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7. Primary CTA (single) -->
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<a id="generateOwn" href="/" style="display:inline-block; color: rgba(0,255,136,0.92); text-decoration:none; font-weight: 650;">
|
||||||
|
Generate your own →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+98
-59
@@ -30,32 +30,60 @@ function buildMidiUrlFromParams(): { midiUrl: string | null; title: string } {
|
|||||||
return { midiUrl: null, title };
|
return { midiUrl: null, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToClipboard(text: string): Promise<void> {
|
function titleCase(input: string): string {
|
||||||
if (navigator.clipboard?.writeText) {
|
const small = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'into', 'nor', 'of', 'on', 'or', 'per', 'the', 'to', 'via', 'with']);
|
||||||
await navigator.clipboard.writeText(text);
|
const words = input
|
||||||
return;
|
.split(/\s+/g)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => w.trim());
|
||||||
|
return words
|
||||||
|
.map((word, idx) => {
|
||||||
|
if (/^[A-Z0-9]+$/.test(word)) return word; // keep acronyms/IDs
|
||||||
|
const lower = word.toLowerCase();
|
||||||
|
if (idx !== 0 && small.has(lower)) return lower;
|
||||||
|
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLooseTitle(raw: string): string {
|
||||||
|
return (raw || '')
|
||||||
|
.replace(/\.mid$/i, '')
|
||||||
|
.replace(/[_]+/g, ' ')
|
||||||
|
.replace(/[.]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayTitle(raw: string): { display: string; prefill: string } {
|
||||||
|
const cleaned = cleanLooseTitle(raw);
|
||||||
|
// If we have common separators "Artist - Title" or "Artist — Title", invert to "Title — Artist"
|
||||||
|
const sepMatch = cleaned.match(/^(.+?)\s*(?:—|-)\s*(.+)$/);
|
||||||
|
if (sepMatch) {
|
||||||
|
const left = titleCase(sepMatch[1].trim());
|
||||||
|
const right = titleCase(sepMatch[2].trim());
|
||||||
|
// Heuristic: if left looks like an artist, invert (most common file naming)
|
||||||
|
const display = `${right} — ${left}`;
|
||||||
|
return { display, prefill: `${right} ${left}`.trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ta = document.createElement('textarea');
|
// Dot-prefix heuristic: "ARTIST.Title" becomes "Title — Artist"
|
||||||
ta.value = text;
|
const dotPrefix = raw.match(/^([A-Za-z]{2,20})[._\s]+(.+)$/);
|
||||||
ta.style.position = 'fixed';
|
if (dotPrefix && dotPrefix[1] && dotPrefix[2]) {
|
||||||
ta.style.left = '-9999px';
|
const artist = titleCase(cleanLooseTitle(dotPrefix[1]));
|
||||||
ta.style.top = '0';
|
const title = titleCase(cleanLooseTitle(dotPrefix[2]));
|
||||||
document.body.appendChild(ta);
|
if (artist && title) return { display: `${title} — ${artist}`, prefill: `${title} ${artist}`.trim() };
|
||||||
ta.focus();
|
}
|
||||||
ta.select();
|
|
||||||
document.execCommand('copy');
|
const t = titleCase(cleaned || 'Shared Motif');
|
||||||
document.body.removeChild(ta);
|
return { display: t, prefill: t };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const titleEl = qs('title');
|
const titleEl = qs('songTitle');
|
||||||
const statusEl = qs('status');
|
const playBtn = qs('playToggleBtn') as HTMLButtonElement;
|
||||||
const playBtn = qs('playBtn') as HTMLButtonElement;
|
|
||||||
const stopBtn = qs('stopBtn') as HTMLButtonElement;
|
|
||||||
const volumeEl = qs('volume') as HTMLInputElement;
|
const volumeEl = qs('volume') as HTMLInputElement;
|
||||||
const generateOwn = qs('generateOwn') as HTMLAnchorElement;
|
const generateOwn = qs('generateOwn') as HTMLAnchorElement;
|
||||||
const shareHint = qs('shareHint');
|
|
||||||
const progressContainer = qs('playProgressContainer') as HTMLElement;
|
const progressContainer = qs('playProgressContainer') as HTMLElement;
|
||||||
const progressBar = qs('playProgressBar') as HTMLInputElement;
|
const progressBar = qs('playProgressBar') as HTMLInputElement;
|
||||||
const progressFill = qs('playProgressFill') as HTMLElement;
|
const progressFill = qs('playProgressFill') as HTMLElement;
|
||||||
@@ -64,16 +92,14 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const { midiUrl: u, title } = buildMidiUrlFromParams();
|
const { midiUrl: u, title } = buildMidiUrlFromParams();
|
||||||
|
|
||||||
titleEl.textContent = title;
|
const formatted = formatDisplayTitle(title);
|
||||||
shareHint.textContent = u ? 'Ready' : 'Missing MIDI URL';
|
titleEl.textContent = formatted.display;
|
||||||
|
|
||||||
// Set CTA back to home, prefilling search if we have a title.
|
// Set CTA back to home, prefilling search if we have a title.
|
||||||
generateOwn.href = title ? `/?song=${encodeURIComponent(title)}` : '/';
|
generateOwn.href = formatted.prefill ? `/?song=${encodeURIComponent(formatted.prefill)}` : '/';
|
||||||
|
|
||||||
if (!u) {
|
if (!u) {
|
||||||
statusEl.textContent = 'Missing link data (u=...).';
|
|
||||||
playBtn.disabled = true;
|
playBtn.disabled = true;
|
||||||
stopBtn.disabled = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,27 +107,24 @@ async function main(): Promise<void> {
|
|||||||
const motifEngine = new MotifEngine();
|
const motifEngine = new MotifEngine();
|
||||||
let events: NoteEvent[] | null = null;
|
let events: NoteEvent[] | null = null;
|
||||||
let isPlaying = false;
|
let isPlaying = false;
|
||||||
|
let isGenerated = false;
|
||||||
let progressInterval: number | null = null;
|
let progressInterval: number | null = null;
|
||||||
let durationSec = 0;
|
let durationSec = 0;
|
||||||
|
let resumeProgress = 0;
|
||||||
|
|
||||||
statusEl.textContent = 'Loading MIDI…';
|
|
||||||
try {
|
try {
|
||||||
const buf = await midiService.fetchMIDI(u);
|
const buf = await midiService.fetchMIDI(u);
|
||||||
if (!buf) throw new Error('Failed to fetch MIDI');
|
if (!buf) throw new Error('Failed to fetch MIDI');
|
||||||
events = MIDIParser.parseMIDI(buf);
|
events = MIDIParser.parseMIDI(buf);
|
||||||
statusEl.textContent = 'Ready. Tap Play.';
|
|
||||||
playBtn.disabled = false;
|
playBtn.disabled = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.textContent = `Load error: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
|
||||||
playBtn.disabled = true;
|
playBtn.disabled = true;
|
||||||
stopBtn.disabled = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUiPlaying(playing: boolean): void {
|
function setUiPlaying(playing: boolean): void {
|
||||||
isPlaying = playing;
|
isPlaying = playing;
|
||||||
playBtn.disabled = playing;
|
playBtn.textContent = playing ? 'Pause' : 'Play';
|
||||||
stopBtn.disabled = !playing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
@@ -116,6 +139,17 @@ async function main(): Promise<void> {
|
|||||||
progressBar.value = (progress * 100).toString();
|
progressBar.value = (progress * 100).toString();
|
||||||
progressFill.style.width = `${progress * 100}%`;
|
progressFill.style.width = `${progress * 100}%`;
|
||||||
currentTimeEl.textContent = formatTime(current);
|
currentTimeEl.textContent = formatTime(current);
|
||||||
|
|
||||||
|
// Auto-pause at end
|
||||||
|
if (durationSec > 0 && progress >= 0.999) {
|
||||||
|
resumeProgress = 0;
|
||||||
|
motifEngine.stop();
|
||||||
|
setUiPlaying(false);
|
||||||
|
stopProgressUpdates();
|
||||||
|
progressBar.value = '100';
|
||||||
|
progressFill.style.width = '100%';
|
||||||
|
currentTimeEl.textContent = formatTime(durationSec);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startProgressUpdates(): void {
|
function startProgressUpdates(): void {
|
||||||
@@ -135,59 +169,64 @@ async function main(): Promise<void> {
|
|||||||
motifEngine.setVolume(vol);
|
motifEngine.setVolume(vol);
|
||||||
});
|
});
|
||||||
|
|
||||||
stopBtn.addEventListener('click', () => {
|
|
||||||
motifEngine.stop();
|
|
||||||
setUiPlaying(false);
|
|
||||||
stopProgressUpdates();
|
|
||||||
statusEl.textContent = 'Stopped.';
|
|
||||||
});
|
|
||||||
|
|
||||||
const seekHandler = (e: Event) => {
|
const seekHandler = (e: Event) => {
|
||||||
const p = Number.parseFloat((e.target as HTMLInputElement).value) / 100;
|
const p = Number.parseFloat((e.target as HTMLInputElement).value) / 100;
|
||||||
|
if (!isGenerated) {
|
||||||
|
resumeProgress = p;
|
||||||
|
progressFill.style.width = `${p * 100}%`;
|
||||||
|
currentTimeEl.textContent = formatTime(p * Math.max(0, durationSec));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPlaying) {
|
||||||
motifEngine.seek(p);
|
motifEngine.seek(p);
|
||||||
updateProgress();
|
updateProgress();
|
||||||
|
} else {
|
||||||
|
resumeProgress = p;
|
||||||
|
progressFill.style.width = `${p * 100}%`;
|
||||||
|
currentTimeEl.textContent = formatTime(p * durationSec);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
progressBar.addEventListener('input', seekHandler);
|
progressBar.addEventListener('input', seekHandler);
|
||||||
progressBar.addEventListener('change', seekHandler);
|
progressBar.addEventListener('change', seekHandler);
|
||||||
|
|
||||||
playBtn.addEventListener('click', async () => {
|
playBtn.addEventListener('click', async () => {
|
||||||
if (!events || isPlaying) return;
|
if (!events) return;
|
||||||
try {
|
|
||||||
setUiPlaying(true);
|
|
||||||
statusEl.textContent = 'Generating…';
|
|
||||||
|
|
||||||
|
// Pause (implemented as stop + remembered position)
|
||||||
|
if (isPlaying) {
|
||||||
|
resumeProgress = motifEngine.getProgress();
|
||||||
|
motifEngine.stop();
|
||||||
|
setUiPlaying(false);
|
||||||
|
stopProgressUpdates();
|
||||||
|
// Keep progress UI where it was
|
||||||
|
progressBar.value = (resumeProgress * 100).toString();
|
||||||
|
progressFill.style.width = `${resumeProgress * 100}%`;
|
||||||
|
currentTimeEl.textContent = formatTime(resumeProgress * durationSec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First play generates the artifact (deterministically from MIDI structure).
|
||||||
|
if (!isGenerated) {
|
||||||
// Match main page "Generate & Play": procedural role-mapping → existing SynthesisEngine.
|
// Match main page "Generate & Play": procedural role-mapping → existing SynthesisEngine.
|
||||||
await motifEngine.generateFromMIDI(events, 'procedural');
|
await motifEngine.generateFromMIDI(events, 'procedural');
|
||||||
motifEngine.setVolume(Number.parseFloat(volumeEl.value));
|
motifEngine.setVolume(Number.parseFloat(volumeEl.value));
|
||||||
durationSec = motifEngine.getDuration();
|
durationSec = motifEngine.getDuration();
|
||||||
durationEl.textContent = formatTime(durationSec);
|
durationEl.textContent = formatTime(durationSec);
|
||||||
currentTimeEl.textContent = '0:00';
|
|
||||||
progressBar.value = '0';
|
|
||||||
progressFill.style.width = '0%';
|
|
||||||
progressContainer.style.display = 'block';
|
progressContainer.style.display = 'block';
|
||||||
|
isGenerated = true;
|
||||||
|
}
|
||||||
|
|
||||||
statusEl.textContent = 'Playing…';
|
setUiPlaying(true);
|
||||||
await motifEngine.play();
|
await motifEngine.play();
|
||||||
|
// Seek to remembered position (for pause/resume and scrub-before-play)
|
||||||
|
if (resumeProgress > 0) motifEngine.seek(resumeProgress);
|
||||||
startProgressUpdates();
|
startProgressUpdates();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setUiPlaying(false);
|
setUiPlaying(false);
|
||||||
stopProgressUpdates();
|
stopProgressUpdates();
|
||||||
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();
|
void main();
|
||||||
|
|||||||
Reference in New Issue
Block a user