rtc_prd/bluetooth.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

469 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Airhub - 搜索设备</title>
<!-- Keep base styles for header/footer consistency -->
<link rel="stylesheet" href="styles.css">
<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">
<style>
/* CRITICAL overrides to ensure visibility */
.bt-content {
display: flex !important;
flex-direction: column !important;
justify-content: flex-start !important;
align-items: stretch !important;
padding-top: 0 !important;
overflow: hidden !important;
position: relative;
}
/* Container for the swiping cards */
#cardContainer {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 480px;
/* Increased height for safety */
/* Adjusted Mask: tighter visible area to ensure things fade out sooner */
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
}
/* The Card - Absolute positioning for transitions */
.device-card-item {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
/* Keep smooth motion */
transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 10;
}
/* States */
/* Center: Visible and centered */
.state-active {
transform: translate(-50%, -50%);
visibility: visible;
}
/* Above: moved UP significantly out of mask area */
.state-exit-up {
transform: translate(-50%, calc(-50% - 350px));
visibility: hidden;
/* Helper to ensure it's gone */
transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0.5s step-end;
}
/* Below: moved DOWN significantly out of mask area */
.state-exit-down {
transform: translate(-50%, calc(-50% + 350px));
visibility: hidden;
/* Helper to ensure it's gone */
transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0.5s step-end;
}
/* Helper to hide completely if needed */
.d-none {
display: none !important;
}
/* Icon Styling - Inline to override global styles.css */
.card-icon-wrapper {
position: relative;
width: 120px;
height: 120px;
display: flex;
justify-content: center;
align-items: center;
}
.card-icon-img {
width: 120px;
height: 120px;
image-rendering: pixelated;
display: block;
/* Eliminate inline spacing */
}
.card-badge {
position: absolute;
top: 0;
right: 0;
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
color: white;
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(139, 92, 246, 0.4);
}
.card-title {
margin-top: 24px;
font-family: 'DM Sans', sans-serif;
font-size: 24px;
font-weight: 600;
color: #1F2937;
text-align: center;
}
.card-subtitle {
margin-top: 4px;
font-family: 'DM Sans', sans-serif;
font-size: 15px;
color: #6B7280;
text-align: center;
}
/* Text at top */
.count-label {
text-align: center;
padding: 20px 0;
font-family: 'DM Sans', sans-serif;
font-size: 14px;
color: #9CA3AF;
transition: opacity 0.3s;
}
.count-active {
color: #8B5CF6;
font-weight: 600;
}
/* Indicators */
.dots-container {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 20;
}
.dot {
width: 6px;
height: 6px;
background: rgba(139, 92, 246, 0.2);
border-radius: 50%;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.dot.active {
height: 18px;
background: #8B5CF6;
border-radius: 3px;
}
/* Error Box (Just in case) */
#errorLog {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255, 0, 0, 0.1);
color: red;
font-size: 10px;
padding: 5px;
display: none;
z-index: 1000;
}
</style>
</head>
<body>
<div class="gradient-bg">
<div class="gradient-layer layer-1"></div>
<div class="gradient-layer layer-2"></div>
<div class="gradient-layer layer-3"></div>
</div>
<div class="page bluetooth-page active">
<header class="bt-header">
<button class="back-btn" onclick="location.href='index.html'">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<h1 class="bt-title">搜索设备</h1>
<div class="header-spacer"></div>
</header>
<main class="bt-content">
<!-- Count Header -->
<div class="count-label" id="countLabel" style="opacity: 0;">正在搜索...</div>
<!-- Cards Container -->
<div id="cardContainer">
<!-- Searching State (Mystery Box) -->
<div id="searchState" style="text-align: center;">
<div class="mystery-box-container">
<div class="mystery-box searching">
<img src="icons/pixel-mystery-box.svg" alt="?" class="mystery-box-icon">
</div>
</div>
<div class="search-status" style="margin-top: 24px;">
<p style="color: #4B5563;">正在搜索附近设备</p>
</div>
</div>
<!-- Card A -->
<div id="cardA" class="device-card-item d-none">
<div class="card-icon-wrapper">
<img id="imgA" class="card-icon-img" src="" alt="">
<span id="badgeA" class="card-badge">AI</span>
</div>
<div id="titleA" class="card-title">Device A</div>
<div id="subA" class="card-subtitle">Type A</div>
</div>
<!-- Card B -->
<div id="cardB" class="device-card-item d-none">
<div class="card-icon-wrapper">
<img id="imgB" class="card-icon-img" src="" alt="">
<span id="badgeB" class="card-badge">AI</span>
</div>
<div id="titleB" class="card-title">Device B</div>
<div id="subB" class="card-subtitle">Type B</div>
</div>
</div>
<!-- Right Dots -->
<div id="dots" class="dots-container" style="display: none;"></div>
</main>
<footer class="bt-footer">
<button class="cancel-btn" id="cancelBtn" onclick="location.href='index.html'">取消搜索</button>
<button class="connect-device-btn" id="connectBtn" style="display: none;" onclick="handleConnect()">
<span class="btn-text">连接设备</span>
</button>
</footer>
</div>
<div id="errorLog"></div>
<!-- No external app.js dependency for core logic to prevent breakages, only navigation helpers -->
<script>
// --- Data ---
const MOCK_DEVICES = [
{ sn: 'PLUSH_01', name: '卡皮巴拉-001', type: 'plush', hasAI: true },
{ sn: 'BADGE_01', name: 'AI电子吧唧-001', type: 'badge_ai', hasAI: true },
{ sn: 'BADGE_02', name: '电子吧唧-001', type: 'badge', hasAI: false },
{ sn: 'PLUSH_02', name: '卡皮巴拉-002', type: 'plush', hasAI: true },
];
// --- Icons Map ---
function getIcon(type) {
if (type === 'plush') return 'icons/pixel-capybara.svg';
if (type === 'badge_ai') return 'icons/pixel-badge-ai.svg';
return 'icons/pixel-badge-basic.svg';
}
function getTypeLabel(type) {
if (type === 'plush') return '毛绒机芯';
if (type === 'badge_ai') return 'AI电子吧唧';
return '普通电子吧唧';
}
// --- State ---
let devices = [];
let curIdx = 0;
let isAnimating = false;
let activeCardId = 'cardA'; // 'cardA' or 'cardB' is the visible one
// --- Init ---
window.addEventListener('load', () => {
try {
// Determine random count
setTimeout(() => {
try {
const count = Math.floor(Math.random() * 4) + 1;
devices = MOCK_DEVICES.slice(0, count);
showResults();
} catch (e) { logError(e); }
}, 2000);
} catch (e) { logError(e); }
});
function logError(e) {
const el = document.getElementById('errorLog');
el.style.display = 'block';
el.textContent = e.toString();
console.error(e);
}
function showResults() {
// Hide search, show first device
document.getElementById('searchState').style.display = 'none';
document.getElementById('connectBtn').style.display = 'flex';
document.getElementById('cancelBtn').innerText = '取消';
// Setup Count Label
const lbl = document.getElementById('countLabel');
lbl.style.opacity = 1;
updateCountLabel();
// Render first card
renderCard('cardA', devices[0]);
const cA = document.getElementById('cardA');
cA.classList.remove('d-none');
cA.classList.add('state-active');
// Setup B as invisible helper
const cB = document.getElementById('cardB');
cB.classList.remove('d-none');
cB.classList.add('state-exit-down'); // initial hidden state
// Setup Dots
if (devices.length > 1) {
document.getElementById('dots').style.display = 'flex';
renderDots();
}
// Setup Touch
setupTouch();
}
function updateCountLabel() {
const el = document.getElementById('countLabel');
if (devices.length > 1) {
el.innerHTML = `找到 <span class="count-active">${devices.length}</span> 个设备 · 滑动切换`;
} else {
el.innerHTML = `找到 <span class="count-active">1</span> 个设备`;
}
}
function renderCard(elementId, device) {
const root = document.getElementById(elementId);
// safe query selectors scoped to root
root.querySelector('.card-icon-img').src = getIcon(device.type);
root.querySelector('.card-badge').style.display = device.hasAI ? 'block' : 'none';
root.querySelector('.card-title').textContent = device.name;
root.querySelector('.card-subtitle').textContent = getTypeLabel(device.type);
}
function renderDots() {
const container = document.getElementById('dots');
let html = '';
for (let i = 0; i < devices.length; i++) {
html += `<div class="dot ${i === curIdx ? 'active' : ''}"></div>`;
}
container.innerHTML = html;
}
// --- Handling Swipe ---
function onSwipe(dir) { // 'up' or 'down'
if (isAnimating || devices.length <= 1) return;
isAnimating = true;
let nextIdx;
if (dir === 'up') {
nextIdx = (curIdx + 1) % devices.length;
} else {
nextIdx = (curIdx - 1 + devices.length) % devices.length;
}
// Determine elements
const currElId = activeCardId;
const nextElId = activeCardId === 'cardA' ? 'cardB' : 'cardA';
const currEl = document.getElementById(currElId);
const nextEl = document.getElementById(nextElId);
// Prepare next element content
renderCard(nextElId, devices[nextIdx]);
// Remove transition for instant layout setup
nextEl.style.transition = 'none';
// Set start positions for animation
if (dir === 'up') {
// Next comes from bottom
nextEl.className = 'device-card-item state-exit-down';
} else {
// Next comes from top
nextEl.className = 'device-card-item state-exit-up';
}
// Force reflow
nextEl.offsetHeight;
// Restore transition
nextEl.style.transition = '';
currEl.style.transition = '';
// Animate to final state
requestAnimationFrame(() => {
if (dir === 'up') {
// Current goes up, Next goes to center
currEl.className = 'device-card-item state-exit-up';
nextEl.className = 'device-card-item state-active';
} else {
// Current goes down, Next goes to center
currEl.className = 'device-card-item state-exit-down';
nextEl.className = 'device-card-item state-active';
}
});
// Update indices
curIdx = nextIdx;
activeCardId = nextElId;
renderDots();
setTimeout(() => {
isAnimating = false;
}, 400);
}
// --- Touch Logic ---
function setupTouch() {
const zone = document.getElementById('cardContainer');
let startY = 0;
zone.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
}, { passive: true });
zone.addEventListener('touchend', (e) => {
const endY = e.changedTouches[0].clientY;
const diff = startY - endY;
if (Math.abs(diff) > 50) {
// diff > 0 means dragging finger UP (content moves UP)
onSwipe(diff > 0 ? 'up' : 'down');
}
}, { passive: true });
zone.addEventListener('wheel', (e) => {
if (Math.abs(e.deltaY) > 20) {
onSwipe(e.deltaY > 0 ? 'up' : 'down');
}
}, { passive: true });
}
function handleConnect() {
const d = devices[curIdx];
localStorage.setItem('lastActiveDevice', JSON.stringify(d));
if (d.type === 'badge') {
location.href = 'device-control.html';
} else {
location.href = 'wifi-config.html';
}
}
</script>
</body>
</html>