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>
523 lines
19 KiB
HTML
523 lines
19 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Airhub - 设备预览器</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
min-height: 100vh;
|
||
background: #0a0a0a;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
padding: 24px 20px;
|
||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
color: white;
|
||
}
|
||
|
||
.preview-layout {
|
||
display: flex;
|
||
gap: 32px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
/* ===== Left Panel: Controls ===== */
|
||
.control-panel {
|
||
width: 200px;
|
||
position: sticky;
|
||
top: 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.device-btn {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 10px 14px;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
background: rgba(255, 255, 255, 0.04);
|
||
color: rgba(255, 255, 255, 0.6);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
transition: all 0.2s ease;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.device-btn:hover {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: white;
|
||
}
|
||
|
||
.device-btn.active {
|
||
background: rgba(99, 102, 241, 0.15);
|
||
border-color: rgba(99, 102, 241, 0.4);
|
||
color: white;
|
||
}
|
||
|
||
.device-btn .size {
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.35);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.device-btn.active .size {
|
||
color: rgba(99, 102, 241, 0.7);
|
||
}
|
||
|
||
.page-btn {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
background: transparent;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
transition: all 0.15s ease;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.page-btn:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: white;
|
||
}
|
||
|
||
.page-btn.active {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: white;
|
||
}
|
||
|
||
.page-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
max-height: 50vh;
|
||
overflow-y: auto;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||
}
|
||
|
||
/* ===== Right: Phone Frame ===== */
|
||
.phone-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.device-info {
|
||
font-size: 13px;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.device-info strong {
|
||
color: white;
|
||
}
|
||
|
||
.iphone-frame {
|
||
position: relative;
|
||
background: #1a1a1a;
|
||
border-radius: 55px;
|
||
padding: 15px;
|
||
box-shadow:
|
||
0 0 0 2px #333,
|
||
0 0 0 4px #1a1a1a,
|
||
0 25px 50px rgba(0, 0, 0, 0.5),
|
||
inset 0 0 0 2px rgba(255, 255, 255, 0.08);
|
||
transition: width 0.3s ease, height 0.3s ease;
|
||
}
|
||
|
||
/* Dynamic Island */
|
||
.iphone-frame::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 18px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 126px;
|
||
height: 37px;
|
||
background: #000;
|
||
border-radius: 20px;
|
||
z-index: 100;
|
||
}
|
||
|
||
/* Notch variant */
|
||
.iphone-frame.notch::before {
|
||
width: 150px;
|
||
height: 32px;
|
||
top: 14px;
|
||
border-radius: 0 0 20px 20px;
|
||
}
|
||
|
||
.iphone-screen {
|
||
border-radius: 42px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
position: relative;
|
||
transition: width 0.3s ease, height 0.3s ease;
|
||
}
|
||
|
||
.iphone-screen iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: none;
|
||
}
|
||
|
||
/* Refresh hint */
|
||
.refresh-btn {
|
||
padding: 8px 20px;
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: rgba(255, 255, 255, 0.6);
|
||
border-radius: 20px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: all 0.2s;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
background: rgba(255, 255, 255, 0.12);
|
||
color: white;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="preview-layout">
|
||
<!-- Left: Controls -->
|
||
<div class="control-panel">
|
||
<!-- Device Selector -->
|
||
<div>
|
||
<div class="panel-title">设备</div>
|
||
<div style="display:flex;flex-direction:column;gap:4px;margin-top:8px;">
|
||
<button class="device-btn active" onclick="setDevice(390, 844, 'iPhone 12 Pro', 'notch', this)">
|
||
iPhone 12 Pro
|
||
<div class="size">390 × 844</div>
|
||
</button>
|
||
<button class="device-btn" onclick="setDevice(393, 852, 'iPhone 16', 'island', this)">
|
||
iPhone 16
|
||
<div class="size">393 × 852</div>
|
||
</button>
|
||
<button class="device-btn" onclick="setDevice(402, 874, 'iPhone 16 Pro', 'island', this)">
|
||
iPhone 16 Pro
|
||
<div class="size">402 × 874</div>
|
||
</button>
|
||
<button class="device-btn" onclick="setDevice(440, 956, 'iPhone 16 Pro Max', 'island', this)">
|
||
iPhone 16 Pro Max
|
||
<div class="size">440 × 956</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Page Navigator -->
|
||
<div>
|
||
<div class="panel-title">页面</div>
|
||
<div class="page-list" style="margin-top:8px;">
|
||
<button class="page-btn active" onclick="goPage('index.html', this)">🏠 首页</button>
|
||
<button class="page-btn" onclick="goPage('bluetooth.html', this)">📡 蓝牙搜索</button>
|
||
<button class="page-btn" onclick="goPage('wifi-config.html', this)">📶 WiFi 配网</button>
|
||
<button class="page-btn" onclick="goPage('products.html', this)">📦 产品选择</button>
|
||
<button class="page-btn" onclick="goPage('device-control.html', this)">🎮 设备控制</button>
|
||
<button class="page-btn" onclick="goPage('music-creation.html', this)">🎵 音乐创作</button>
|
||
<button class="page-btn" onclick="goPage('story-detail.html', this)">📖 故事详情</button>
|
||
<button class="page-btn" onclick="goPage('story-loading.html', this)">⏳ 故事加载</button>
|
||
<button class="page-btn" onclick="goPage('profile.html', this)">👤 个人中心</button>
|
||
<button class="page-btn" onclick="goPage('profile-info.html', this)">📝 个人信息</button>
|
||
<button class="page-btn" onclick="goPage('settings.html', this)">⚙️ 设置</button>
|
||
<button class="page-btn" onclick="goPage('login.html', this)">🔐 登录</button>
|
||
<button class="page-btn" onclick="goPage('notifications.html', this)">🔔 通知</button>
|
||
<button class="page-btn" onclick="goPage('guide-feeding.html', this)">🍽️ 喂食指南</button>
|
||
<button class="page-btn" onclick="goPage('help.html', this)">❓ 帮助</button>
|
||
<button class="page-btn" onclick="goPage('privacy.html', this)">🔒 隐私政策</button>
|
||
<button class="page-btn" onclick="goPage('agreement.html', this)">📄 用户协议</button>
|
||
<button class="page-btn" onclick="goPage('agent-manage.html', this)">🤖 Agent 管理</button>
|
||
<button class="page-btn" onclick="goPage('collection-list.html', this)">📋 信息收集清单</button>
|
||
<button class="page-btn" onclick="goPage('sharing-list.html', this)">🔗 信息共享清单</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Phone -->
|
||
<div class="phone-area">
|
||
<div class="device-info">
|
||
<strong id="device-name">iPhone 12 Pro</strong>
|
||
·
|
||
<span id="device-size">390 × 844</span>
|
||
·
|
||
<span id="page-name">index.html</span>
|
||
</div>
|
||
|
||
<div class="iphone-frame notch" id="phone-frame">
|
||
<div class="iphone-screen" id="phone-screen" style="width:390px;height:844px;">
|
||
<iframe src="index.html" id="app-frame"></iframe>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="refresh-btn" onclick="refreshFrame()">↻ 刷新预览</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentPage = 'index.html';
|
||
|
||
function setDevice(width, height, name, type, btn) {
|
||
const screen = document.getElementById('phone-screen');
|
||
const frame = document.getElementById('phone-frame');
|
||
|
||
screen.style.width = width + 'px';
|
||
screen.style.height = height + 'px';
|
||
frame.style.width = (width + 30) + 'px';
|
||
frame.style.height = (height + 40) + 'px';
|
||
|
||
// Notch vs Dynamic Island
|
||
frame.className = 'iphone-frame ' + type;
|
||
|
||
document.getElementById('device-name').textContent = name;
|
||
document.getElementById('device-size').textContent = width + ' × ' + height;
|
||
|
||
// Update active button
|
||
document.querySelectorAll('.device-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
|
||
refreshFrame();
|
||
}
|
||
|
||
function goPage(page, btn) {
|
||
currentPage = page;
|
||
document.getElementById('app-frame').src = page;
|
||
document.getElementById('page-name').textContent = page;
|
||
|
||
document.querySelectorAll('.page-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
}
|
||
|
||
function refreshFrame() {
|
||
const iframe = document.getElementById('app-frame');
|
||
iframe.src = currentPage + '?t=' + Date.now();
|
||
}
|
||
|
||
// Keyboard shortcut: Ctrl+R to refresh frame
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||
e.preventDefault();
|
||
refreshFrame();
|
||
}
|
||
});
|
||
|
||
// Set initial frame size
|
||
document.getElementById('phone-frame').style.width = '420px';
|
||
document.getElementById('phone-frame').style.height = '884px';
|
||
|
||
// =============================================
|
||
// Touch Simulation - Inject into iframe
|
||
// =============================================
|
||
const TOUCH_CSS = `
|
||
/* Hide default cursor, show nothing */
|
||
*, *::before, *::after {
|
||
cursor: none !important;
|
||
}
|
||
|
||
/* The finger dot */
|
||
#__touch-dot {
|
||
position: fixed;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.08) 70%, transparent 100%);
|
||
box-shadow: 0 0 8px rgba(0,0,0,0.1);
|
||
pointer-events: none;
|
||
z-index: 99999;
|
||
transform: translate(-50%, -50%) scale(1);
|
||
transition: transform 0.08s ease, opacity 0.15s ease;
|
||
opacity: 0;
|
||
will-change: transform, left, top;
|
||
}
|
||
#__touch-dot.visible { opacity: 1; }
|
||
#__touch-dot.pressed {
|
||
transform: translate(-50%, -50%) scale(0.75);
|
||
background: radial-gradient(circle, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.12) 70%, transparent 100%);
|
||
}
|
||
|
||
/* Tap ripple */
|
||
.__touch-ripple {
|
||
position: fixed;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.08);
|
||
pointer-events: none;
|
||
z-index: 99998;
|
||
transform: translate(-50%, -50%) scale(1);
|
||
animation: __ripple-expand 0.45s ease-out forwards;
|
||
}
|
||
@keyframes __ripple-expand {
|
||
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
|
||
100% { transform: translate(-50%, -50%) scale(8); opacity: 0; }
|
||
}
|
||
|
||
/* Disable text selection while swiping */
|
||
body.__touch-swiping,
|
||
body.__touch-swiping * {
|
||
user-select: none !important;
|
||
-webkit-user-select: none !important;
|
||
}
|
||
`;
|
||
|
||
const TOUCH_JS = `
|
||
(function() {
|
||
if (window.__touchSimLoaded) return;
|
||
window.__touchSimLoaded = true;
|
||
|
||
// Create dot element
|
||
const dot = document.createElement('div');
|
||
dot.id = '__touch-dot';
|
||
document.body.appendChild(dot);
|
||
|
||
let isDown = false;
|
||
let startX = 0, startY = 0;
|
||
let lastX = 0, lastY = 0;
|
||
let hasMoved = false;
|
||
const SWIPE_THRESHOLD = 5;
|
||
|
||
// Find the best scrollable parent
|
||
function getScrollParent(el) {
|
||
while (el && el !== document.body) {
|
||
const style = getComputedStyle(el);
|
||
const overflowY = style.overflowY;
|
||
if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
|
||
return el;
|
||
}
|
||
el = el.parentElement;
|
||
}
|
||
// Fallback: try document.scrollingElement or body
|
||
if (document.scrollingElement && document.scrollingElement.scrollHeight > document.scrollingElement.clientHeight) {
|
||
return document.scrollingElement;
|
||
}
|
||
return document.body;
|
||
}
|
||
|
||
let scrollTarget = null;
|
||
|
||
// Move dot
|
||
document.addEventListener('mousemove', (e) => {
|
||
dot.style.left = e.clientX + 'px';
|
||
dot.style.top = e.clientY + 'px';
|
||
dot.classList.add('visible');
|
||
|
||
if (isDown) {
|
||
const dx = e.clientX - lastX;
|
||
const dy = e.clientY - lastY;
|
||
|
||
if (!hasMoved && (Math.abs(e.clientX - startX) > SWIPE_THRESHOLD || Math.abs(e.clientY - startY) > SWIPE_THRESHOLD)) {
|
||
hasMoved = true;
|
||
document.body.classList.add('__touch-swiping');
|
||
}
|
||
|
||
if (hasMoved && scrollTarget) {
|
||
// Invert: drag down = scroll up (like real phone)
|
||
scrollTarget.scrollTop -= dy;
|
||
scrollTarget.scrollLeft -= dx;
|
||
}
|
||
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
}
|
||
}, { passive: true });
|
||
|
||
// Hide dot when mouse leaves
|
||
document.addEventListener('mouseleave', () => {
|
||
dot.classList.remove('visible');
|
||
});
|
||
document.addEventListener('mouseenter', () => {
|
||
dot.classList.add('visible');
|
||
});
|
||
|
||
// Press
|
||
document.addEventListener('mousedown', (e) => {
|
||
isDown = true;
|
||
hasMoved = false;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
dot.classList.add('pressed');
|
||
|
||
// Find scrollable parent of the target
|
||
scrollTarget = getScrollParent(e.target);
|
||
});
|
||
|
||
// Release
|
||
document.addEventListener('mouseup', (e) => {
|
||
if (isDown && !hasMoved) {
|
||
// It was a tap, not a swipe — show ripple
|
||
const ripple = document.createElement('div');
|
||
ripple.className = '__touch-ripple';
|
||
ripple.style.left = e.clientX + 'px';
|
||
ripple.style.top = e.clientY + 'px';
|
||
document.body.appendChild(ripple);
|
||
setTimeout(() => ripple.remove(), 500);
|
||
}
|
||
|
||
isDown = false;
|
||
hasMoved = false;
|
||
scrollTarget = null;
|
||
dot.classList.remove('pressed');
|
||
document.body.classList.remove('__touch-swiping');
|
||
});
|
||
|
||
// Prevent default drag behavior inside the app
|
||
document.addEventListener('dragstart', (e) => e.preventDefault());
|
||
})();
|
||
`;
|
||
|
||
function injectTouchSim(iframe) {
|
||
try {
|
||
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||
if (!doc || !doc.body) return;
|
||
|
||
// Inject CSS
|
||
const style = doc.createElement('style');
|
||
style.id = '__touch-sim-css';
|
||
style.textContent = TOUCH_CSS;
|
||
doc.head.appendChild(style);
|
||
|
||
// Inject JS
|
||
const script = doc.createElement('script');
|
||
script.id = '__touch-sim-js';
|
||
script.textContent = TOUCH_JS;
|
||
doc.body.appendChild(script);
|
||
} catch (e) {
|
||
console.warn('Touch sim injection failed (cross-origin?):', e);
|
||
}
|
||
}
|
||
|
||
// Inject on every iframe load
|
||
const appFrame = document.getElementById('app-frame');
|
||
appFrame.addEventListener('load', () => injectTouchSim(appFrame));
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|