rtc_prd/music-creation.html
seaislee1209 066eb8f820 feat: music-creation page + MiniMax API integration + Flutter dev setup
Music Creation Page:
- Vinyl 3D flip to view lyrics, tonearm animation, glow rotation effect
- Circular SVG progress ring, speech bubble feedback, confirm dialog
- Playlist modal, free creation input, lyrics formatting optimization
- MiniMax API real music generation with SSE streaming progress

Backend:
- FastAPI proxy server.py for MiniMax API calls
- Music + lyrics file persistence to Capybara music/ directory
- GET /api/playlist endpoint for auto-building playlist from files

UI/UX Refinements:
- frontend-design skill compliance across all pages
- Glassmorphism effects, modal interactions, scroll tap prevention
- iPhone 12 Pro responsive layout (390x844)

Flutter Development Preparation:
- Installed flutter-expert skill with 6 reference docs
- Added 5 Cursor Rules: official Flutter, clean architecture, UI performance, testing, Dart standards

Assets:
- 9 Capybara music MP3 files + lyrics TXT files
- MiniMax API documentation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 18:23:19 +08:00

2256 lines
81 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Airhub - 水豚灵感电台</title>
<!-- Preload Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<style>
/* UI Pro Max - Design System Overrides */
:root {
--glass-bg: rgba(255, 255, 255, 0.65);
--glass-border: rgba(255, 255, 255, 0.4);
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
--primary-gradient: linear-gradient(135deg, #ECCFA8 0%, #C99672 100%);
--text-main: #1F2937;
/* slate-900 */
--text-muted: #4B5563;
/* slate-600 */
}
body {
font-family: 'DM Sans', sans-serif;
-webkit-tap-highlight-color: transparent;
}
/* Layout Structure */
.page-header {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 16px 20px;
padding-top: calc(env(safe-area-inset-top, 20px) + 48px);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 50;
}
/* Header Icon Button */
.icon-btn {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.4);
width: 40px;
height: 40px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #1F2937;
transition: all 0.2s;
cursor: pointer;
}
.icon-btn:active {
transform: scale(0.92);
background: rgba(255, 255, 255, 0.4);
}
.icon-btn svg {
width: 22px;
height: 22px;
}
/* Header Icon Button (Playlist) - With Label */
.page-header .playlist-btn {
position: absolute;
right: 16px;
top: calc(env(safe-area-inset-top, 20px) + 48px);
/* Aligned with header spec */
transform: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: transparent;
border: none;
cursor: pointer;
color: #1F2937;
transition: all 0.2s;
padding: 4px 8px;
}
.page-header .playlist-btn svg {
width: 22px;
height: 22px;
}
.page-header .playlist-btn .btn-label {
font-size: 9px;
color: #6B7280;
font-weight: 500;
}
.page-header .playlist-btn:active {
transform: scale(0.92);
opacity: 0.7;
}
/* Progress Bar Section */
.progress-section {
display: flex;
align-items: center;
width: 100%;
max-width: 340px;
margin: 6px auto 0;
padding: 0 16px;
box-sizing: border-box;
min-height: 48px;
position: relative;
z-index: 100;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(8px);
border-radius: 24px;
}
/* Lyrics marquee removed — lyrics now on vinyl back */
.time-label {
font-size: 12px;
color: #6B7280;
min-width: 36px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.progress-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
margin: 0 8px;
background: #E5E5EA;
border-radius: 3px;
cursor: pointer;
outline: none;
}
.progress-slider::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
background: transparent;
border-radius: 3px;
}
.progress-slider::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(139, 94, 60, 0.3);
margin-top: -6px;
border: none;
}
/* Firefox */
.progress-slider::-moz-range-track {
width: 100%;
height: 6px;
background: #E5E5EA;
border-radius: 3px;
}
.progress-slider::-moz-range-thumb {
height: 18px;
width: 18px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(139, 94, 60, 0.3);
border: none;
}
/* Progress Play Button */
.progress-play-btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 8px;
margin-left: -16px;
/* Align with capsule edge */
transition: all 0.2s;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.progress-play-btn svg {
width: 24px;
height: 24px;
}
.progress-play-btn:active {
transform: scale(0.92);
background: rgba(255, 255, 255, 1);
}
/* Icon Toggle Logic */
.progress-play-btn .pause-icon {
display: none;
}
.progress-play-btn .play-icon {
display: block;
}
.progress-section.playing .progress-play-btn .play-icon {
display: none;
}
.progress-section.playing .progress-play-btn .pause-icon {
display: block;
}
.back-btn {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.back-btn:active {
transform: scale(0.92);
}
.page-title {
font-size: 17px;
font-weight: 600;
color: var(--text-main);
letter-spacing: -0.01em;
}
.main-container {
padding-top: calc(env(safe-area-inset-top) + 100px);
padding-bottom: calc(env(safe-area-inset-bottom) + 90px);
padding-left: 16px;
padding-right: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 6px;
min-height: 100vh;
max-width: 100vw;
box-sizing: border-box;
overflow-x: hidden;
}
/* Vinyl Player (Visual Core) */
.player-visual-wrapper {
perspective: 800px;
width: 210px;
height: 210px;
margin: 8px 0 16px 0;
position: relative;
filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.2));
}
/* ── Tonearm ── */
.tonearm {
position: absolute;
top: -8px;
right: 18px;
width: 80px;
height: 100px;
z-index: 25;
pointer-events: none;
transform-origin: 62px 12px; /* pivot at the base knob */
transform: rotate(-55deg);
transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.tonearm.playing {
transform: rotate(-25deg);
}
.tonearm-arm {
position: absolute;
top: 12px;
right: 16px;
width: 3px;
height: 70px;
background: linear-gradient(180deg, #A0A0A0, #C0C0C0);
border-radius: 1.5px;
transform-origin: top center;
transform: rotate(25deg);
}
.tonearm-head {
position: absolute;
bottom: -6px;
left: -3px;
width: 9px;
height: 10px;
background: linear-gradient(180deg, #888, #666);
border-radius: 1px 1px 2px 2px;
}
.tonearm-base {
position: absolute;
top: 4px;
right: 8px;
width: 18px;
height: 18px;
background: radial-gradient(circle at 40% 40%, #D0D0D0, #909090);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
}
.tonearm-base::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
background: radial-gradient(circle at 40% 40%, #E8E8E8, #B0B0B0);
border-radius: 50%;
}
.player-visual {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1);
cursor: pointer;
}
.player-visual.flipped {
transform: rotateY(180deg);
}
.vinyl-front,
.vinyl-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: 50%;
}
.vinyl-front {
z-index: 2;
}
.vinyl-back {
transform: rotateY(180deg);
background: #18181B;
background-image: repeating-radial-gradient(#18181B 0, #18181B 3px, #1f1f23 4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
border: 3px solid rgba(236, 207, 168, 0.25);
}
.lyrics-container {
width: 150px;
height: 150px;
overflow-y: auto;
overflow-x: hidden;
display: flex;
align-items: flex-start;
justify-content: center;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.lyrics-container::-webkit-scrollbar {
display: none;
}
.lyrics-content {
color: rgba(255, 255, 255, 0.75);
font-family: 'DM Sans', sans-serif;
font-size: 11px;
line-height: 1.5;
text-align: center;
white-space: pre-line; /* respect \n in text, wrap if still too long */
white-space: pre-line;
padding: 8px 4px;
}
.lyrics-content .lyrics-placeholder {
color: rgba(255, 255, 255, 0.35);
font-style: italic;
font-size: 11px;
}
.vinyl-disc {
width: 100%;
height: 100%;
border-radius: 50%;
background: #18181B;
/* zinc-900 */
background-image: repeating-radial-gradient(#18181B 0, #18181B 3px, #27272A 4px);
position: absolute;
top: 0;
left: 0;
display: flex;
/* Centering content if needed */
align-items: center;
justify-content: center;
animation: spin 6s linear infinite;
animation-play-state: paused;
}
.vinyl-disc::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(
from 30deg,
transparent 0deg,
rgba(255, 255, 255, 0.05) 12deg,
rgba(255, 255, 255, 0.11) 25deg,
rgba(255, 255, 255, 0.05) 38deg,
transparent 50deg,
transparent 180deg,
rgba(255, 255, 255, 0.05) 192deg,
rgba(255, 255, 255, 0.11) 205deg,
rgba(255, 255, 255, 0.05) 218deg,
transparent 230deg,
transparent 360deg
);
pointer-events: none;
}
.vinyl-disc.spinning {
animation-play-state: running;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.album-cover {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 130px;
height: 130px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(236, 207, 168, 0.6);
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.1), inset 0 0 20px rgba(0, 0, 0, 0.5);
z-index: 2;
/* Static: No rotation, transition only for load/change */
transition: opacity 0.3s;
}
/* Play/Pause overlay removed — controls now via progress bar only */
/* Player Area: contains bubble + vinyl wrapper */
.player-area {
position: relative;
width: 210px;
margin: 0 auto;
}
/* Capybara Speech Bubble (warm, soft, clip-path unified shape) */
.capy-speech-bubble {
position: absolute;
top: 2px;
right: -24px;
transform: scale(0.7) translateY(8px);
background: rgba(253, 247, 237, 0.93);
/* backdrop-filter removed: leaks outside clip-path in some browsers */
padding: 8px 16px 16px 16px;
/* No border-radius or border — clip-path handles the shape */
filter: drop-shadow(0 0 0.5px rgba(236, 207, 168, 0.45))
drop-shadow(0 3px 12px rgba(139, 94, 60, 0.10));
font-size: 12.5px;
font-weight: 500;
color: #6B4423;
max-width: 200px;
white-space: nowrap;
opacity: 0;
transform-origin: bottom left;
transition: opacity 0.2s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 50;
pointer-events: none;
}
.capy-speech-bubble.visible {
opacity: 1;
transform: scale(1) translateY(0);
}
/* Inspiration Grid */
.inspiration-section {
width: 100%;
max-width: 400px;
margin-top: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
/* Distribute space */
}
.section-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
padding-left: 4px;
letter-spacing: 0.02em;
}
.mood-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.mood-card {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 1px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
position: relative;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01);
}
.mood-card:active {
transform: scale(0.96);
background: rgba(255, 255, 255, 0.8);
}
.mood-card.active {
border-color: #ECCFA8;
background: white;
box-shadow: 0 10px 25px -5px rgba(236, 207, 168, 0.4);
}
.mood-card.active::after {
content: '';
position: absolute;
top: 12px;
right: 12px;
width: 8px;
height: 8px;
background: #C99672;
border-radius: 50%;
}
.mood-icon {
font-size: 24px;
margin-bottom: 2px;
}
.mood-title {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
.mood-desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
/* Mystery Box Style */
.mood-card.mystery {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, rgba(236, 207, 168, 0.3) 100%);
}
/* Custom Input Style */
.mood-card.custom {
border: 1px dashed var(--primary-color);
background: rgba(255, 255, 255, 0.6);
}
/* Global Input Field */
.input-wrapper {
width: 100%;
max-width: 400px;
position: relative;
margin-top: 4px;
}
.magic-input {
width: 100%;
border: none;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 16px 56px 16px 20px;
font-size: 15px;
color: var(--text-main);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
transition: all 0.2s;
box-sizing: border-box;
font-family: inherit;
/* Auto-expand for multi-line */
min-height: 56px;
max-height: 120px;
resize: none;
overflow-y: auto;
line-height: 1.4;
}
.magic-input:focus {
outline: none;
background: white;
box-shadow: 0 8px 24px rgba(236, 207, 168, 0.25);
border-color: #ECCFA8;
}
.magic-input::placeholder {
color: #9CA3AF;
}
.send-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 20px;
border: none;
background: var(--primary-gradient);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(201, 150, 114, 0.3);
transition: transform 0.2s;
}
.send-btn:active {
transform: translateY(-50%) scale(0.9);
}
.send-btn:disabled {
opacity: 0.6;
filter: grayscale(1);
cursor: not-allowed;
}
/* Removed Old Toast Styles */
/* Loading Animation in Vinyl */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.loading-overlay.show {
opacity: 1;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: rotate 1s linear infinite;
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
/* ── Circular Generation Progress Ring ── */
.gen-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 224px;
height: 224px;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease;
z-index: 30;
}
.gen-ring.show {
opacity: 1;
}
.gen-ring svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.gen-ring .track {
fill: none;
stroke: rgba(255, 255, 255, 0.12);
stroke-width: 3;
}
.gen-ring .bar {
fill: none;
stroke: url(#genGradient);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 678.58;
stroke-dashoffset: 678.58;
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 0 4px rgba(236, 207, 168, 0.5));
}
.gen-ring .glow {
fill: none;
stroke: rgba(236, 207, 168, 0.15);
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 678.58;
stroke-dashoffset: 678.58;
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Bottom Navigation - Matching profile.html */
.dc-footer {
position: fixed;
bottom: 30px;
left: 0;
right: 0;
display: flex;
justify-content: center;
padding: 0 20px;
box-sizing: border-box;
z-index: 100;
}
.menu-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.8);
padding: 6px 8px;
border-radius: 32px;
width: 100%;
max-width: 320px;
box-shadow: 0 10px 30px rgba(139, 92, 246, 0.15);
}
.menu-item {
width: 56px;
height: 56px;
border-radius: 28px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
position: relative;
cursor: pointer;
}
.menu-item img {
width: 28px;
height: 28px;
object-fit: contain;
opacity: 0.7;
transition: all 0.3s;
filter: grayscale(100%) opacity(0.6);
}
.menu-item.active {
background: linear-gradient(90deg, #E6B98D 0%, #E8C9A8 35%, #D4A373 70%, #B07D5A 100%);
box-shadow: 0 4px 15px rgba(212, 163, 115, 0.4);
transform: translateY(-2px) scale(1.05);
}
.menu-item.active img {
opacity: 1;
transform: scale(1.1);
filter: brightness(0) invert(1);
}
/* Playlist Modal - Full Width Bottom Sheet */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: none;
align-items: flex-end;
/* Bottom Sheet alignment */
justify-content: center;
/* Center horizontally if max-width is used */
animation: fadeIn 0.3s forwards;
padding-bottom: 0;
/* Flush with bottom */
/* CRITICAL FIX: Override global styles.css which sets pointer-events: none */
pointer-events: auto;
}
.glass-modal {
width: 100%;
max-width: 100vw;
margin: 0;
left: 0;
right: 0;
bottom: 0;
position: absolute;
box-sizing: border-box;
display: flex;
flex-direction: column;
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
pointer-events: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 0;
}
.modal-header h3 {
margin: 0;
font-size: 15px;
color: var(--text-main);
font-weight: 600;
}
.close-btn {
background: rgba(0, 0, 0, 0.05);
border: none;
width: 28px;
height: 28px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--text-muted);
cursor: pointer;
transition: background 0.2s;
}
.close-btn:active {
background: rgba(0, 0, 0, 0.1);
}
.playlist-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 40px;
padding-top: 6px;
/* Top fade — matches app-wide standard (30px transition zone) */
-webkit-mask-image: linear-gradient(to bottom, transparent 0px, black 30px, black 100%);
mask-image: linear-gradient(to bottom, transparent 0px, black 30px, black 100%);
/* Hide Scrollbar */
scrollbar-width: none;
-ms-overflow-style: none;
}
.playlist-content::-webkit-scrollbar {
display: none;
/* Chrome/Safari */
}
.record-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
/* 3 Columns is standard */
gap: 8px;
/* Tighter gap (8px) */
padding-bottom: 20px;
}
/* New Slot Style */
.record-slot {
background: rgba(0, 0, 0, 0.03);
border-radius: 12px;
padding: 10px 4px;
/* Narrower padding */
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba(0, 0, 0, 0.02);
}
.record-slot:active {
background: rgba(0, 0, 0, 0.06);
transform: scale(0.98);
}
.record-item {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.record-cover-wrapper {
width: 100%;
aspect-ratio: 1;
background: #18181B;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
/* Stronger shadow for 3D feel */
transition: transform 0.2s;
}
.record-item:active .record-cover-wrapper {
transform: scale(0.95);
}
.record-cover-wrapper::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
background: repeating-radial-gradient(#18181B 0, #18181B 2px, #27272A 3px);
z-index: 0;
}
.record-inner-img {
width: 55%;
height: 55%;
border-radius: 50%;
object-fit: cover;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.2);
z-index: 1;
}
/* Playing Indicator on Cover */
.record-item.playing .record-cover-wrapper {
box-shadow: 0 0 0 2px #ECCFA8, 0 8px 16px rgba(0, 0, 0, 0.15);
}
.record-title {
font-size: 12px;
text-align: center;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.empty-state {
text-align: center;
color: var(--text-muted);
margin-top: 60px;
font-size: 14px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.empty-state::before {
content: '📦';
font-size: 32px;
opacity: 0.5;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* Modal Overlay (Shared) */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: none;
/* Default hidden */
align-items: flex-end;
/* Align to bottom */
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
backdrop-filter: blur(4px);
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.input-modal-container {
width: 100%;
max-width: 100%;
padding: 16px 20px;
padding-bottom: calc(24px + env(safe-area-inset-bottom));
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 24px 24px 0 0;
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.06);
}
/* Playlist Modal Container */
.playlist-container {
width: 100%;
max-width: 100%;
max-height: 88vh;
padding: 16px 20px;
padding-bottom: calc(24px + env(safe-area-inset-bottom));
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
background: rgba(255, 255, 255, 0.95);
border-radius: 24px 24px 0 0;
backdrop-filter: blur(20px);
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.06);
overflow-y: hidden;
}
.input-area {
display: flex;
flex-direction: column;
gap: 10px;
}
.input-area .magic-input {
border-radius: 16px;
padding: 12px 16px;
font-size: 14px;
min-height: 44px;
max-height: 100px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: none;
}
.input-area .magic-input:focus {
background: white;
border-color: #ECCFA8;
box-shadow: 0 0 0 3px rgba(236, 207, 168, 0.15);
}
.send-btn-modal {
width: 100%;
height: 44px;
border-radius: 22px;
border: none;
background: var(--primary-gradient);
color: white;
font-size: 15px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(236, 207, 168, 0.3);
cursor: pointer;
transition: all 0.2s;
}
.send-btn-modal:active {
transform: scale(0.98);
}
/* Confirm Dialog (new song ready) */
.confirm-container {
width: calc(100% - 48px);
max-width: 320px;
margin: auto;
padding: 20px 24px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
text-align: center;
animation: fadeIn 0.25s ease;
}
.confirm-text {
margin: 0 0 18px 0;
font-size: 15px;
font-weight: 600;
color: var(--text-main);
line-height: 1.5;
}
.confirm-actions {
display: flex;
gap: 10px;
}
.confirm-btn {
flex: 1;
height: 40px;
border-radius: 20px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.confirm-btn:active {
transform: scale(0.96);
}
.confirm-btn.secondary {
background: rgba(0, 0, 0, 0.06);
color: var(--text-muted);
}
.confirm-btn.primary {
background: var(--primary-gradient);
color: white;
box-shadow: 0 4px 12px rgba(236, 207, 168, 0.35);
}
</style>
</head>
<body>
<!-- Background Gradient -->
<div class="gradient-bg">
<div class="gradient-layer layer-1"></div>
<div class="gradient-layer layer-2"></div>
</div>
<!-- Playlist Modal Overlay -->
<div class="modal-overlay" id="playlistModal" style="display: none;">
<div class="playlist-container glass-modal">
<div class="modal-header">
<h3>我的唱片架</h3>
<button class="close-btn" onclick="togglePlaylist()">×</button>
</div>
<div class="playlist-content" id="playlistGrid">
<!-- Playlist Items Injected JS -->
<div class="empty-state">
暂无收藏唱片
</div>
</div>
</div>
</div>
<!-- Header -->
<header class="page-header">
<!-- Back Button (Left) -->
<button class="icon-btn" onclick="window.history.back()" title="返回">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<!-- Title (Center) -->
<div class="page-title">灵感电台</div>
<!-- Playlist Button (Right) -->
<button class="icon-btn" onclick="togglePlaylist()" title="唱片架">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="7" height="7" rx="1"></rect>
<rect x="14" y="3" width="7" height="7" rx="1"></rect>
<rect x="3" y="14" width="7" height="7" rx="1"></rect>
<rect x="14" y="14" width="7" height="7" rx="1"></rect>
</svg>
</button>
</header>
<main class="main-container">
<!-- Player Area: bubble + vinyl -->
<div class="player-area">
<div class="capy-speech-bubble" id="capyBubble">正在为您编曲...</div>
<div class="player-visual-wrapper">
<div class="player-visual" onclick="flipVinyl()">
<!-- Tonearm (inside 3D container so it flips with vinyl) -->
<div class="tonearm" id="tonearm">
<div class="tonearm-base"></div>
<div class="tonearm-arm">
<div class="tonearm-head"></div>
</div>
</div>
<!-- Front: Vinyl Player -->
<div class="vinyl-front">
<div class="vinyl-disc" id="vinylDisc"></div>
<img src="Capybara.png" class="album-cover" id="albumCover">
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
</div>
<!-- Back: Lyrics -->
<div class="vinyl-back" id="vinylBack">
<div class="lyrics-container">
<div class="lyrics-content" id="lyricsContent">
<span class="lyrics-placeholder">生成音乐后<br>点我看歌词</span>
</div>
</div>
</div>
</div>
<!-- Circular Generation Progress Ring -->
<div class="gen-ring" id="genRing">
<svg viewBox="0 0 224 224">
<defs>
<linearGradient id="genGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ECCFA8"/>
<stop offset="50%" stop-color="#D4A76A"/>
<stop offset="100%" stop-color="#ECCFA8"/>
</linearGradient>
</defs>
<circle class="track" cx="112" cy="112" r="108"/>
<circle class="glow" cx="112" cy="112" r="108"/>
<circle class="bar" cx="112" cy="112" r="108"/>
</svg>
</div>
</div>
</div>
<!-- Progress Bar Section with Controls -->
<div class="progress-section">
<!-- Play/Pause Button -->
<button class="progress-play-btn" onclick="togglePlay()">
<svg class="play-icon" width="20" height="20" viewBox="0 0 24 24" fill="#1F2937">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
<svg class="pause-icon" width="20" height="20" viewBox="0 0 24 24" fill="#1F2937">
<rect x="6" y="4" width="4" height="16"></rect>
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
</button>
<span class="time-label" id="currentTime">0:00</span>
<input type="range" class="progress-slider" id="progressBar" min="0" max="100" value="0">
<span class="time-label" id="totalTime">0:00</span>
</div>
<!-- Hidden Audio Element -->
<audio id="audioPlayer" loop preload="none"></audio>
<!-- Inspiration Section -->
<div class="inspiration-section">
<div class="section-label">今天心情怎么样?</div>
<div class="mood-grid">
<!-- Chill -->
<div class="mood-card" onclick="selectMood('chill', '泡个热水澡')">
<div class="mood-icon">🛁</div>
<div class="mood-title">Chill Lofi</div>
<div class="mood-desc">慵懒 · 治愈 · 水声</div>
</div>
<!-- Energy -->
<div class="mood-card" onclick="selectMood('happy', '出去撒点野')">
<div class="mood-icon">🏃</div>
<div class="mood-title">Happy Funk</div>
<div class="mood-desc">活力 · 奔跑 · 阳光</div>
</div>
<!-- Sleep -->
<div class="mood-card" onclick="selectMood('chill', '睡个好觉')">
<div class="mood-icon">💤</div>
<div class="mood-title">Deep Sleep</div>
<div class="mood-desc">白噪音 · 助眠 · 梦境</div>
</div>
<!-- Focus (New) -->
<div class="mood-card" onclick="selectMood('chill', '专注时刻')">
<div class="mood-icon">🧠</div>
<div class="mood-title">Focus Flow</div>
<div class="mood-desc">心流 · 专注 · 效率</div>
</div>
<!-- Mystery Box -->
<div class="mood-card mystery" onclick="selectMood('happy', '来点惊喜')">
<div class="mood-icon">🎁</div>
<div class="mood-title">盲盒惊喜</div>
<div class="mood-desc">AI 随机生成神曲</div>
</div>
<!-- Custom Input (New) -->
<div class="mood-card custom" onclick="toggleInputModal(true)">
<div class="mood-icon"></div>
<div class="mood-title">自由创作</div>
<div class="mood-desc">输入灵感 · 生成音乐</div>
</div>
</div>
</div>
</main>
<!-- 底部导航 -->
<footer class="dc-footer">
<nav class="menu-bar">
<div class="menu-item" onclick="location.href='device-control.html'">
<img src="icons/icon-home-capybara.svg" alt="Home">
</div>
<div class="menu-item" onclick="location.href='device-control.html?tab=story'">
<img src="icons/icon-story-pixel.svg" alt="Story">
</div>
<div class="menu-item active">
<img src="icons/icon-music-pixel.svg" alt="Music">
</div>
<div class="menu-item" onclick="location.href='profile.html'">
<img src="icons/icon-user-pixel.svg" alt="User">
</div>
</nav>
</footer>
<!-- Status Toast (Removed / Hidden) -->
<!-- Replaced by Speech Bubble -->
<script>
// State
const STATE = {
isGenerating: false,
isPlaying: false,
isFlipped: false,
currentMood: null,
hasAudio: false,
currentLyrics: '',
lyricsHintShown: false
};
// DOM Elements (modalInput is after <script>, resolved lazily)
const els = {
vinyl: document.getElementById('vinylDisc'),
audio: document.getElementById('audioPlayer'),
input: null, // resolved after DOM ready
loader: document.getElementById('loadingOverlay'),
bubble: document.getElementById('capyBubble'),
cards: document.querySelectorAll('.mood-card'),
lyricsContent: document.getElementById('lyricsContent')
};
// Resolve late DOM elements
document.addEventListener('DOMContentLoaded', () => {
els.input = document.getElementById('modalInput');
// Initial clip-path for bubble (in case it has default text)
updateBubbleClip();
});
// Helper: Update bubble clip-path to iMessage-style shape with tail
// Synchronous — reading offsetWidth/Height forces reflow so clip-path
// is always set before the bubble becomes visible.
function updateBubbleClip() {
const b = els.bubble;
if (!b) return;
const W = b.offsetWidth; // forces synchronous reflow
const H = b.offsetHeight;
if (!W || !H) return;
const R = 16; // corner radius
const tailH = 8; // tail height below body
const tailW = 10; // tail base width
const tailX = 14; // tail left-edge X
const BH = H - tailH; // body bottom Y
// SVG path: rounded rect (Q curves for corners) + tail at bottom-left
const d = [
`M ${R},0`,
`L ${W - R},0`,
`Q ${W},0 ${W},${R}`,
`L ${W},${BH - R}`,
`Q ${W},${BH} ${W - R},${BH}`,
`L ${tailX + tailW},${BH}`,
`L ${tailX + 2},${H}`,
`L ${tailX},${BH}`,
`L ${R},${BH}`,
`Q 0,${BH} 0,${BH - R}`,
`L 0,${R}`,
`Q 0,0 ${R},0`,
`Z`
].join(' ');
b.style.clipPath = `path("${d}")`;
b.style.webkitClipPath = `path("${d}")`;
}
// Helper: Show Speech Bubble
function showSpeech(text, duration = 3000) {
if (!els.bubble) return;
els.bubble.textContent = text;
els.bubble.classList.add('visible');
updateBubbleClip();
// Clear existing timer if any
if (els.bubble.timer) clearTimeout(els.bubble.timer);
if (duration > 0) {
els.bubble.timer = setTimeout(() => {
els.bubble.classList.remove('visible');
}, duration);
}
}
// Vinyl Flip Logic
function flipVinyl() {
const visual = document.querySelector('.player-visual');
if (!STATE.isFlipped) {
// Flip to back (lyrics)
visual.classList.add('flipped');
STATE.isFlipped = true;
// Pause spinning while flipped (music keeps playing)
els.vinyl.classList.remove('spinning');
} else {
// Flip back to front
visual.classList.remove('flipped');
STATE.isFlipped = false;
// Resume spinning if playing
if (STATE.isPlaying) {
els.vinyl.classList.add('spinning');
}
}
}
// Back side: distinguish tap (flip back) from scroll (read lyrics)
(function () {
const back = document.getElementById('vinylBack');
if (!back) return;
let startY = 0;
let startX = 0;
let moved = false;
let insideLyrics = false;
const lyricsEl = back.querySelector('.lyrics-container');
back.addEventListener('pointerdown', (e) => {
startX = e.clientX;
startY = e.clientY;
moved = false;
// Check if the touch/click started inside the scrollable lyrics area
insideLyrics = lyricsEl && lyricsEl.contains(e.target);
}, { passive: true });
back.addEventListener('pointermove', (e) => {
const dx = Math.abs(e.clientX - startX);
const dy = Math.abs(e.clientY - startY);
if (dx > 6 || dy > 6) moved = true;
}, { passive: true });
back.addEventListener('pointerup', (e) => {
e.stopPropagation();
// If user was scrolling lyrics, don't flip
if (insideLyrics && moved) return;
// Only flip back on clean tap (no scrolling)
if (!moved) flipVinyl();
});
// Prevent parent onclick from firing when interacting with back
back.addEventListener('click', (e) => {
e.stopPropagation();
});
})();
// Update Lyrics Display
function updateLyrics(lyrics) {
STATE.currentLyrics = lyrics || '';
if (!els.lyricsContent) return;
if (!lyrics || lyrics.trim() === '') {
els.lyricsContent.innerHTML = '<span class="lyrics-placeholder">生成音乐后<br>点我看歌词</span>';
} else {
// Clean MiniMax structure tags & reformat for narrow container
const cleaned = lyrics
.replace(/\[(Verse|Chorus|Intro|Outro|Bridge|Hook|Pre-Chorus|Post-Chorus|Interlude|Break|Inst|Solo|Build-?up|Transition|Drop|Breakdown).*?\]/gi, '')
.replace(/\n{2,}/g, '\n') // collapse multiple blank lines
.replace(/^\s+|\s+$/gm, '') // trim each line
.replace(/^\n+/, '') // remove leading newline
.trim()
// Split at Chinese punctuation & remove the punctuation itself
.replace(/[,。、;]/g, '\n') // replace comma/period/enumeration/semicolon with newline
.replace(/[]/g, '\n') // replace exclamation/question mark with newline
.replace(/\n{2,}/g, '\n') // re-collapse any doubled newlines
.replace(/\n+$/, '') // remove trailing newline
.trim();
els.lyricsContent.textContent = cleaned;
}
// Scroll lyrics back to top
const container = els.lyricsContent.parentElement;
if (container) container.scrollTop = 0;
// First-time lyrics hint
if (lyrics && lyrics.trim() && !STATE.lyricsHintShown) {
STATE.lyricsHintShown = true;
setTimeout(() => showSpeech('点我看歌词哦~', 3000), 500);
}
}
// --- Confirm Dialog: "New song ready, listen now?" ---
// Temporary storage for newly generated song awaiting user decision
let _pendingSong = null; // { audioUrl, lyrics, title }
function showConfirm(audioUrl, lyrics, title) {
_pendingSong = { audioUrl, lyrics, title };
const modal = document.getElementById('confirmModal');
modal.style.display = 'flex';
requestAnimationFrame(() => modal.classList.add('active'));
}
function confirmListen(yes) {
// Close dialog
const modal = document.getElementById('confirmModal');
modal.classList.remove('active');
setTimeout(() => { modal.style.display = 'none'; }, 300);
if (!_pendingSong) return;
const { audioUrl, lyrics, title } = _pendingSong;
_pendingSong = null;
// Always add to playlist
addToPlaylist(title, lyrics, audioUrl);
if (yes) {
// Switch to new song
playNewSong(audioUrl, lyrics, title);
} else {
showSpeech('已加入唱片架,随时可以听', 2500);
}
}
// Helper: play a newly generated song (shared by confirm-yes and direct-play paths)
function playNewSong(audioUrl, lyrics, title) {
els.audio.src = audioUrl;
els.audio.play().then(() => {
STATE.hasAudio = true;
STATE.isPlaying = true;
els.vinyl.style.animation = '';
els.vinyl.style.transform = '';
els.vinyl.classList.add('spinning');
setTonearm(true);
els.playerVisual.classList.remove('paused');
els.playerVisual.classList.add('playing');
document.querySelector('.progress-section').classList.add('playing');
showSpeech('正在播放: ' + (title || '新歌'), 3000);
if (lyrics) updateLyrics(lyrics);
// Flip back to front if flipped
if (STATE.isFlipped) flipVinyl();
}).catch(e => {
console.log('Auto-play blocked:', e);
showSpeech('点击播放按钮播放', 3000);
});
}
// Close confirm dialog on overlay tap (deferred element is below in the DOM)
document.addEventListener('DOMContentLoaded', () => {
const confirmModal = document.getElementById('confirmModal');
if (confirmModal) {
confirmModal.addEventListener('click', (e) => {
if (e.target === confirmModal) {
confirmListen(false);
}
});
}
});
// Interaction Logic
function selectMood(mood, defaultText) {
if (STATE.isGenerating) return showSpeech('音乐正在生成中,请稍等哦~', 2500);
// Update UI (Highlight clicked card)
els.cards.forEach(c => c.classList.remove('active'));
// Check if triggered by event and it's a card
if (event && event.currentTarget && event.currentTarget.classList.contains('mood-card')) {
event.currentTarget.classList.add('active');
} else if (mood === 'custom') {
document.querySelector('.mood-card.custom').classList.add('active');
}
// Set State
STATE.currentMood = mood;
// For custom, we don't overwrite if it's already set (handled by submitCustomInput)
// For presets, we set the default text
if (mood !== 'custom' && els.input) {
els.input.value = defaultText;
}
// Trigger Generation
handleGenerate();
}
// ── Circular Progress Ring Helpers ──
const GEN_CIRCUMFERENCE = 2 * Math.PI * 108; // ≈ 678.58
let _genProgressTimer = null;
function setGenProgress(pct) {
const ring = document.getElementById('genRing');
if (!ring) return;
ring.classList.add('show');
const offset = GEN_CIRCUMFERENCE * (1 - Math.min(pct, 100) / 100);
ring.querySelector('.bar').style.strokeDashoffset = offset;
ring.querySelector('.glow').style.strokeDashoffset = offset;
}
function hideGenProgress() {
clearInterval(_genProgressTimer);
_genProgressTimer = null;
const ring = document.getElementById('genRing');
if (!ring) return;
ring.classList.remove('show');
setTimeout(() => {
ring.querySelector('.bar').style.strokeDashoffset = GEN_CIRCUMFERENCE;
ring.querySelector('.glow').style.strokeDashoffset = GEN_CIRCUMFERENCE;
}, 500);
}
// Slowly crawl from current% toward target% (simulates progress during long waits)
function crawlProgress(from, to, durationMs) {
clearInterval(_genProgressTimer);
let current = from;
const step = (to - from) / (durationMs / 300);
_genProgressTimer = setInterval(() => {
current = Math.min(current + step, to);
setGenProgress(current);
if (current >= to) clearInterval(_genProgressTimer);
}, 300);
}
// Generate Handler — background generation with SSE streaming progress
async function handleGenerate() {
if (STATE.isGenerating) return;
const inputText = (els.input ? els.input.value : '').trim();
if (!inputText && !STATE.currentMood) return showSpeech('请先选个心情或写句话吧 👇');
const wasPlaying = STATE.isPlaying;
// 1. Start generation UI
STATE.isGenerating = true;
if (!wasPlaying) {
els.loader.classList.add('show');
els.vinyl.classList.remove('spinning');
els.vinyl.style.transform = 'rotate(0deg)';
els.vinyl.style.animation = 'none';
}
showSpeech('🎼 正在连接 AI...', 0);
setGenProgress(5);
if (els.input) els.input.disabled = true;
const songTitle = inputText || 'AI 灵感神曲 #' + (myPlaylist.length + 1);
try {
// 2. SSE streaming API call
const response = await fetch('http://localhost:3000/api/create_music', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: inputText, mood: STATE.currentMood || 'custom' })
});
if (!response.ok) throw new Error('Server Error');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let result = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete line in buffer
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const evt = JSON.parse(line.slice(6));
// Update UI with each event
if (evt.message) showSpeech(evt.message, 0);
if (evt.progress) setGenProgress(evt.progress);
// Start crawl animation during long music gen stage
if (evt.stage === 'music') {
crawlProgress(30, 85, 35000);
}
if (evt.stage === 'done') {
clearInterval(_genProgressTimer);
setGenProgress(100);
result = evt;
} else if (evt.stage === 'error') {
throw new Error(evt.message || '生成失败');
}
} catch (parseErr) {
if (parseErr.message && !parseErr.message.includes('JSON')) throw parseErr;
}
}
}
// 3. Handle result
if (result && (result.file_path || result.audio_hex)) {
let audioUrl;
if (result.file_path) {
audioUrl = result.file_path;
} else {
audioUrl = URL.createObjectURL(hexToBlob(result.audio_hex));
}
const lyrics = (result.metadata && result.metadata.lyrics) || '';
if (STATE.isPlaying) {
showConfirm(audioUrl, lyrics, songTitle);
} else {
addToPlaylist(songTitle, lyrics, audioUrl);
playNewSong(audioUrl, lyrics, songTitle);
}
} else {
throw new Error('No result from server');
}
} catch (err) {
console.warn('Generate error:', err);
hideGenProgress();
// Check if it's a network error (server unreachable) vs API/generation error
const isNetworkError = err.name === 'TypeError' && err.message.includes('fetch');
if (isNetworkError) {
// Server not running → mock fallback
const mockLyrics = '卡皮巴拉\n啦啦啦啦\n卡皮巴拉\n啦啦啦啦\n\n卡皮巴拉 蹦蹦蹦\n一整天都 在发疯\n卡皮巴拉 转一圈\n左一脚 右一脚 (嘿)\n\n卡皮巴拉 蹦蹦蹦\n洗脑节奏 响空中\n卡皮巴拉 不要停\n跟着我 一起疯\n\n一口菜叶 卡一巴\n两口草莓 巴一拉\n三口西瓜 啦一啦\n嘴巴圆圆 哈哈哈 (哦耶)';
const mockAudioUrl = myPlaylist[0] ? myPlaylist[0].audioUrl : '';
showSpeech('🎼 AI 正在创作词曲...', 0);
setGenProgress(10);
await new Promise(r => setTimeout(r, 800));
showSpeech('🎵 正在生成音乐...', 0);
setGenProgress(50);
await new Promise(r => setTimeout(r, 700));
setGenProgress(100);
showSpeech('✨ (演示模式) 新歌出炉!', 3000);
if (STATE.isPlaying) {
showConfirm(mockAudioUrl, mockLyrics, songTitle);
} else {
addToPlaylist(songTitle, mockLyrics, mockAudioUrl);
if (mockAudioUrl) {
playNewSong(mockAudioUrl, mockLyrics, songTitle);
}
}
} else {
// API returned an error or timeout → show failure message
showSpeech('生成失败了,再试一次吧~', 4000);
}
} finally {
STATE.isGenerating = false;
els.loader.classList.remove('show');
if (els.input) els.input.disabled = false;
setTimeout(hideGenProgress, 800);
// Clear inline animation override so CSS .spinning class works again
els.vinyl.style.animation = '';
els.vinyl.style.transform = '';
}
}
// Helper: Hex to Blob
function hexToBlob(hex, type = 'audio/mpeg') {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return new Blob([bytes], { type: type });
}
// Play/Pause Toggle (only via progress bar button)
function togglePlay() {
if (!STATE.hasAudio) {
return showSpeech('请先生成一首音乐哦 🎹');
}
const progressSection = document.querySelector('.progress-section');
if (els.audio.paused) {
els.audio.play();
STATE.isPlaying = true;
// Resume spin only if not flipped
if (!STATE.isFlipped) {
els.vinyl.classList.add('spinning');
}
setTonearm(true);
// Update Overlay State
els.playerVisual.classList.remove('paused');
els.playerVisual.classList.add('playing');
// Update Progress Section
progressSection.classList.add('playing');
} else {
els.audio.pause();
STATE.isPlaying = false;
els.vinyl.classList.remove('spinning'); // Stop spin
setTonearm(false);
// Return to upright logic (Reset animation)
setTimeout(() => {
els.vinyl.style.animation = 'none';
els.vinyl.offsetHeight; /* trigger reflow */
els.vinyl.style.animation = '';
}, 50);
// Update Overlay State
els.playerVisual.classList.remove('playing');
els.playerVisual.classList.add('paused');
// Update Progress Section
progressSection.classList.remove('playing');
}
}
// Add listener for audio ending
els.audio.addEventListener('ended', () => {
STATE.isPlaying = false;
els.vinyl.classList.remove('spinning');
setTonearm(false);
showSpeech('播放完成 🎵', 2000);
// Reset to upright
setTimeout(() => {
els.vinyl.style.animation = 'none';
els.vinyl.offsetHeight; /* trigger reflow */
els.vinyl.style.animation = '';
}, 50);
});
// Helper access for togglePlay style
els.playerVisual = document.querySelector('.player-visual');
els.tonearm = document.getElementById('tonearm');
// Tonearm helper — call whenever play state changes
function setTonearm(playing) {
if (!els.tonearm) return;
if (playing) {
els.tonearm.classList.add('playing');
} else {
els.tonearm.classList.remove('playing');
}
}
// Progress Bar Elements
const progressBar = document.getElementById('progressBar');
const currentTimeEl = document.getElementById('currentTime');
const totalTimeEl = document.getElementById('totalTime');
// Format time helper
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Update progress bar as audio plays
els.audio.addEventListener('timeupdate', () => {
if (els.audio.duration) {
const percent = (els.audio.currentTime / els.audio.duration) * 100;
progressBar.value = percent;
currentTimeEl.textContent = formatTime(els.audio.currentTime);
// Update track gradient
progressBar.style.background = `linear-gradient(to right, #E8C9A8 ${percent}%, #E5E5EA ${percent}%)`;
}
});
// Set total time when metadata loads
els.audio.addEventListener('loadedmetadata', () => {
totalTimeEl.textContent = formatTime(els.audio.duration);
});
// Seek when user drags progress bar
progressBar.addEventListener('input', () => {
if (els.audio.duration) {
els.audio.currentTime = (progressBar.value / 100) * els.audio.duration;
}
});
// Playlist Data (Real Music Files)
const myPlaylist = [
{
id: 1,
title: '卡皮巴拉蹦蹦蹦',
cover: 'Capybara.png',
audioUrl: 'Capybara music/卡皮巴拉蹦蹦蹦.mp3',
mood: 'happy',
lyrics: '卡皮巴拉\n啦啦啦啦\n卡皮巴拉\n啦啦啦啦\n\n卡皮巴拉 蹦蹦蹦\n一整天都 在发疯\n卡皮巴拉 转一圈\n左一脚 右一脚 (嘿)\n\n卡皮巴拉 蹦蹦蹦\n洗脑节奏 响空中\n卡皮巴拉 不要停\n跟着我 一起疯\n\n一口菜叶 卡一巴\n两口草莓 巴一拉\n三口西瓜 啦一啦\n嘴巴圆圆 哈哈哈 (哦耶)\n\n卡皮巴拉 蹦蹦蹦\n一整天都 在发疯\n卡皮巴拉 转一圈\n左一脚 右一脚 (嘿)\n\n卡皮巴拉 蹦蹦蹦\n洗脑节奏 响空中\n卡皮巴拉 不要停\n跟着我 一起疯\n\n卡皮 卡皮 巴拉巴拉\n巴拉 巴拉 卡皮卡皮 (嘿嘿)\n听我 听我 跟着跟着\n一句 一句 重复重复\n\n卡皮巴拉 蹦蹦蹦\n一整天都 在发疯\n卡皮巴拉 转一圈\n左一脚 右一脚 (嘿)\n\n卡皮巴拉 蹦蹦蹦\n洗脑节奏 响空中\n卡皮巴拉 到天黑\n明天起床 继续疯'
},
{
id: 2,
title: '卡皮巴拉快乐水',
cover: 'Capybara.png',
audioUrl: 'Capybara music/卡皮巴拉快乐水.mp3',
mood: 'chill',
lyrics: '卡皮巴拉\n卡皮巴拉\n卡皮巴拉\n啦啦啦啦\n\n卡皮巴拉趴地上\n一动不动好嚣张\n心里其实在上网\n刷到我就笑出响 (哈哈哈)\n\n卡皮巴拉 巴拉巴拉\n压力来啦 它说算啦\n一点不慌 就是躺啦\n世界太吵 它在发呆呀\n卡皮巴拉 巴拉巴拉\n烦恼滚啦 快乐到家\n跟着一起 嗷嗷嗷啊\n脑子空空 只剩它\n\n卡皮巴拉喝奶茶\n珍珠全都被它夸\n"生活苦就多加糖"\n说完继续装木桩 (嘿呀)\n\n卡皮巴拉 巴拉巴拉\n压力来啦 它说算啦\n一点不慌 就是躺啦\n世界太吵 它在发呆呀\n卡皮巴拉 巴拉巴拉\n烦恼滚啦 快乐到家\n跟着一起 嗷嗷嗷啊\n脑子空空 只剩它\n\n今天不卷\n只卷床单 (嘿)\n今天不忙\n只忙可爱\n跟它一起\n放空发呆\n一二三\n什么也不想\n\n卡皮巴拉 巴拉巴拉\n压力来啦 它说算啦\n一点不慌 就是躺啦\n世界太吵 它在发呆呀\n卡皮巴拉 巴拉巴拉\n烦恼滚啦 快乐到家\n跟着一起 嗷嗷嗷啊\n脑子空空 只剩它'
},
{
id: 3,
title: '卡皮巴拉快乐营业',
cover: 'Capybara.png',
audioUrl: 'Capybara music/卡皮巴拉快乐营业.mp3',
mood: 'happy',
lyrics: '早八打工人\n心却躺平人\n桌面壁纸换上\n卡皮巴拉一整屏 (嘿)\n\n它坐在河边\n像个退休中年\n我卷生卷死\n它只发呆发呆再发呆\n办公室拉满\n我是表情包主演\n老板在开会\n我在偷看它泡温泉\n\n卡皮巴拉 卡皮巴拉 拉\n看你就把压力清空啦 (啊对对对)\n谁骂我韭菜我就回他\n我已经转职水豚啦\n卡皮巴拉 卡皮巴拉 拉\n世界很吵你超安静呀 (好家伙)\n人生好难打 打不过就挂\n挂一张你 我立刻满血复活啦\n\n朋友失恋了\n眼泪刷刷往下掉\n我发一张图\n"兄弟先学习一下松弛感"\n外卖又涨价\n工位又多一堆活\n但你眯着眼\n像在说"一切都还来得及"\n\n卡皮巴拉 卡皮巴拉 拉\n看你就把压力清空啦 (真香啊)\n谁骂我社恐我就说他\n我只会和水豚社交啊\n卡皮巴拉 卡皮巴拉 拉\n世界很丧你很治愈呀 (稳住别浪)\n生活再刮风 风大也不怕\n抱紧你的图 我就自带防护罩\n\n升职加薪没我\n摸鱼排行榜有我 (懂的都懂)\n卷不赢卷王\n那我就卷成你同款发呆模样\n左手放空\n右手放松\n嘴里默念八个大字\n"开心就好 随缘躺平"\n\n卡皮巴拉 卡皮巴拉 拉\n看你就把压力清空啦 (我悟了)\n谁劝我上进我就回他\n"先学会像水豚活着吧"\n卡皮巴拉 卡皮巴拉 拉\n世界很吵你超安静呀 (哈人啊)\n如果有一天 run 不动啦\n我就去投胎 做一只卡皮巴拉'
},
{
id: 4,
title: '卡皮巴拉快乐趴',
cover: 'Capybara.png',
audioUrl: 'Capybara music/卡皮巴拉快乐趴.mp3',
mood: 'chill',
lyrics: '今天不上班\n卡皮巴拉躺平在沙滩\n小小太阳帽\n草帽底下梦见一整片菜园 (好香哦)\n一口咔咔青菜\n两口嘎嘎胡萝卜\n吃着吃着打个嗝\n"我是不是一只蔬菜发动机?"\n\n卡皮巴拉啦啦啦\n快乐像病毒一样传染呀\n你一笑 它一哈\n全场都在哈哈哈\n卡皮巴拉吧啦吧\n烦恼直接按下删除呀\n一起躺 平平趴\n世界马上变得好融化\n\n同桌小鸭鸭\n排队要跟它合个影\n河马举个牌\n"主播别跑看这边一点" (比个耶)\n它说"别催我\n我在加载快乐进度条"\n百分之一百满格\n"叮——情绪已经自动修复"\n\n卡皮巴拉啦啦啦\n快乐像病毒一样传染呀\n你一笑 它一哈\n全场都在哈哈哈\n卡皮巴拉吧啦吧\n烦恼直接按下删除呀\n一起躺 平平趴\n世界马上变得好融化\n\n作业山太高\n先发一张可爱自拍\n配文写\n"今天也被温柔的小动物拯救了嗷" (冲呀)\n心情掉电时\n就喊出那个暗号——\n"三二一 一起喊"\n"卡皮巴拉 拯救我!"\n\n卡皮巴拉啦啦啦\n快乐像病毒一样传染呀\n你一笑 它一哈\n全场都在哈哈哈\n卡皮巴拉吧啦吧\n烦恼直接按下删除呀\n小朋友 大朋友\n跟着一起摇摆唱起歌\n卡皮巴拉 卡皮巴拉\n明天继续来给你治愈呀'
}
];
// Set Default Audio (First track from hardcoded list as fallback)
els.audio.src = myPlaylist[0].audioUrl;
STATE.hasAudio = true;
updateLyrics(myPlaylist[0].lyrics);
// Try to load full playlist from backend (includes generated songs)
fetch('http://localhost:3000/api/playlist')
.then(r => r.json())
.then(data => {
if (data.playlist && data.playlist.length) {
myPlaylist.length = 0; // clear hardcoded
data.playlist.forEach((item, i) => {
myPlaylist.push({
id: i + 1,
title: item.title,
cover: 'Capybara.png',
audioUrl: item.audioUrl,
mood: 'happy',
lyrics: item.lyrics
});
});
// Update default audio to first track
if (!STATE.isPlaying) {
els.audio.src = myPlaylist[0].audioUrl;
updateLyrics(myPlaylist[0].lyrics);
}
console.log('[Playlist] Loaded ' + myPlaylist.length + ' tracks from server');
}
})
.catch(() => {
console.log('[Playlist] Backend unavailable, using hardcoded playlist');
});
// Toggle Playlist Modal
// Toggle Playlist Modal
function togglePlaylist() {
const modal = document.getElementById('playlistModal');
if (modal.classList.contains('active')) {
modal.classList.remove('active');
setTimeout(() => {
modal.style.display = 'none';
}, 300);
} else {
modal.style.display = 'flex';
// Delay for transition
requestAnimationFrame(() => {
modal.classList.add('active');
});
renderPlaylist();
}
}
// Initialize Playlist Modal (Force Close)
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('playlistModal');
if (modal) {
modal.classList.remove('active');
modal.style.display = 'none';
}
});
// Playlist scroll-vs-tap guard: ignore taps that happen right after scrolling
let _playlistScrolling = false;
let _playlistScrollTimer;
(function () {
const pc = document.getElementById('playlistGrid');
if (!pc) return;
pc.addEventListener('scroll', () => {
_playlistScrolling = true;
clearTimeout(_playlistScrollTimer);
_playlistScrollTimer = setTimeout(() => { _playlistScrolling = false; }, 200);
}, { passive: true });
})();
// Render Playlist
function renderPlaylist() {
const grid = document.getElementById('playlistGrid');
if (myPlaylist.length === 0) {
grid.innerHTML = '<div class="empty-state">暂无收藏唱片</div>';
return;
}
grid.innerHTML = '<div class="record-grid">' +
myPlaylist.map(item => `
<div class="record-slot" onclick="if(!_playlistScrolling) playFromList(${item.id})">
<div class="record-item">
<div class="record-cover-wrapper">
<img src="${item.cover}" class="record-inner-img">
</div>
<div class="record-title">${item.title}</div>
</div>
</div>
`).join('') +
'</div>';
}
// Play from List (Real Audio)
function playFromList(id) {
const track = myPlaylist.find(i => i.id === id);
if (track) {
showSpeech(`正在播放: ${track.title}`, 2000);
togglePlaylist(); // Close modal
// Update lyrics
updateLyrics(track.lyrics || '');
// If flipped, flip back to front
if (STATE.isFlipped) {
flipVinyl();
}
// Load and Play Real Audio
const progressSection = document.querySelector('.progress-section');
if (track.audioUrl) {
els.audio.src = track.audioUrl;
els.audio.play().then(() => {
STATE.hasAudio = true;
STATE.isPlaying = true;
els.vinyl.style.animation = '';
els.vinyl.style.transform = '';
els.vinyl.classList.add('spinning');
setTonearm(true);
els.playerVisual.classList.remove('paused');
els.playerVisual.classList.add('playing');
progressSection.classList.add('playing');
}).catch(err => {
console.warn('Playback blocked:', err);
showSpeech('点击播放按钮播放 🎵', 2000);
});
} else {
// Fallback for tracks without audio
STATE.hasAudio = true;
STATE.isPlaying = true;
els.vinyl.style.animation = '';
els.vinyl.style.transform = '';
els.vinyl.classList.add('spinning');
setTonearm(true);
els.playerVisual.classList.remove('paused');
els.playerVisual.classList.add('playing');
progressSection.classList.add('playing');
}
}
}
// Add to Playlist (Called on Success)
function addToPlaylist(title, lyrics, audioUrl) {
const newTrack = {
id: Date.now(),
title: title || '未命名乐章',
cover: 'Capybara.png',
audioUrl: audioUrl || '',
mood: STATE.currentMood,
lyrics: lyrics || STATE.currentLyrics || ''
};
myPlaylist.unshift(newTrack);
renderPlaylist();
}
// Close modals on outside click (tap the dimmed overlay area)
document.getElementById('playlistModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('playlistModal')) {
togglePlaylist();
}
});
// Deferred inputModal element is below in the DOM
document.addEventListener('DOMContentLoaded', () => {
const inputModal = document.getElementById('inputModal');
if (inputModal) {
inputModal.addEventListener('click', (e) => {
if (e.target === inputModal) {
toggleInputModal(false);
}
});
}
});
// Toggle Input Modal
function toggleInputModal(show) {
const modal = document.getElementById('inputModal');
if (show) {
modal.style.display = 'flex';
// Small delay to allow display reflow before adding active class for transition
requestAnimationFrame(() => {
modal.classList.add('active');
setTimeout(() => document.getElementById('modalInput').focus(), 100);
});
} else {
modal.classList.remove('active');
// Wait for transition to finish before hiding
setTimeout(() => {
modal.style.display = 'none';
}, 300);
}
}
// Initialize (Force Close)
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('inputModal');
if (modal) {
modal.classList.remove('active');
modal.style.display = 'none';
}
});
// Submit Custom Input
function submitCustomInput() {
const input = document.getElementById('modalInput');
const prompt = input.value.trim();
if (!prompt) {
showSpeech('请输入一点灵感吧 ✨');
return;
}
toggleInputModal(false);
// Set input value for generation
// We set it to the shared input (modalInput) so handleGenerate can read it
// (Since els.input points to modalInput now)
// Manually trigger selectMood logic for 'custom'
selectMood('custom', prompt);
}
</script>
<!-- Custom Input Modal -->
<div class="modal-overlay" id="inputModal" style="display: none;">
<div class="input-modal-container glass-modal">
<div class="modal-header">
<h3>自由创作</h3>
<button class="close-btn" onclick="toggleInputModal(false)">×</button>
</div>
<div class="input-area">
<textarea id="modalInput" class="magic-input" placeholder="例如:水豚在雨中等公交..." rows="3"></textarea>
<button class="send-btn-modal" onclick="submitCustomInput()">
生成音乐 🎵
</button>
</div>
</div>
</div>
<!-- Confirm Dialog: New song ready -->
<div class="modal-overlay" id="confirmModal" style="display: none;">
<div class="confirm-container">
<p class="confirm-text">新歌已生成,是否立即试听?</p>
<div class="confirm-actions">
<button class="confirm-btn secondary" onclick="confirmListen(false)">稍后再听</button>
<button class="confirm-btn primary" onclick="confirmListen(true)">立即试听</button>
</div>
</div>
</div>
</body>
</html>