rtc_prd/story-detail.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

639 lines
27 KiB
HTML
Raw Permalink 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>
<link rel="stylesheet" href="styles.css?v=6">
<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>
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
/* Prevent body scroll */
background: #FDF9F3;
}
.result-container {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--header-padding-top) 24px 110px;
/* Space for footer */
max-width: 600px;
margin: 0 auto;
position: relative;
}
.dc-header {
flex-shrink: 0;
/* Header stays fixed */
position: relative;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
}
.back-btn-custom {
position: absolute;
left: 0;
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: #4B5563;
}
/* Story Card becomes the Scroll Container */
.story-paper {
flex: 1;
/* Fill remaining space */
overflow-y: auto;
/* Internal Scroll */
margin-bottom: 10px;
/* Hide Scrollbar for App Feel */
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE/Edge */
}
/* Video Mode - Maximize Content */
.story-paper.video-mode {
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
}
/* Webkit Scrollbar styling (Chrome/Safari) */
.story-paper::-webkit-scrollbar {
display: none;
}
/* Typography Polish */
.story-content {
font-size: 16px;
line-height: 2.0;
color: #374151;
text-align: justify;
}
.story-content p {
margin-bottom: 20px;
text-indent: 2em;
}
/* Fullscreen Button */
.fullscreen-btn {
position: absolute;
bottom: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
z-index: 20;
/* Above Click Overlay */
backdrop-filter: blur(4px);
transition: all 0.2s;
}
.fullscreen-btn:active {
transform: scale(0.9);
background: rgba(0, 0, 0, 0.7);
}
/* In-Card Loading State */
.video-loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
color: #4B5563;
}
</style>
</head>
<body>
<!-- Remove separate loading mask, moved inside card -->
<!-- Custom Modal Overlay -->
<div class="modal-overlay" id="custom-modal">
<div class="glass-modal">
<div class="modal-title" id="modal-title">提示</div>
<div class="modal-desc" id="modal-desc">内容文本</div>
<div class="modal-actions">
<button class="modal-btn cancel" id="modal-cancel">取消</button>
<button class="modal-btn confirm" id="modal-confirm">确定</button>
</div>
</div>
</div>
<!-- Custom Toast -->
<div class="custom-toast" id="custom-toast">
✨ 操作成功
</div>
<!-- Main Scroll Container -->
<div class="result-container" id="main-container">
<!-- Header -->
<header class="dc-header" id="page-header">
<button class="back-btn-custom" onclick="window.location.href='device-control.html?tab=story'">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M15 18l-6-6 6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="header-title" style="font-weight:700; color:#4B2404; font-size: 18px;">星际忍者的茶话会</div>
</header>
<!-- Tab Switcher (Hidden by default) -->
<div class="tab-switch-container" id="content-tabs" style="display:none;">
<div class="tab-switch">
<button class="switch-btn active" id="tab-text" onclick="switchContentTab('text')">📄 故事</button>
<button class="switch-btn" id="tab-video" onclick="switchContentTab('video')">🎬 绘本</button>
</div>
</div>
<!-- Story Content Card -->
<div class="story-paper" id="story-card">
<!-- Text Content -->
<div class="story-content" id="view-text">
<p>
在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。
</p>
<p>
“这儿的重力好像有点不对劲?”波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。
</p>
<p>
突然,桌上的魔法茶壶“噗”地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:“别打架,别打架,喝了这杯‘银河气泡茶’,我们都是好朋友!”
</p>
<p>
于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。
</p>
</div>
<!-- Video Content (Hidden by default) -->
<div class="video-view-container" id="view-video">
<!-- Loading Wrapper -->
<div class="video-loading-state" id="video-loading-state" style="display:none;">
<div class="loader-spinner"
style="border-width: 3px; width: 40px; height: 40px; border-top-color: #F43F5E;"></div>
<div style="font-weight:600; margin-top:16px;">AI 正在绘制动态绘本...</div>
<div style="font-size:12px; color:#9CA3AF; margin-top:8px;">消耗 10 SP</div>
</div>
<!-- Video Wrapper -->
<div id="video-content-wrapper">
<video class="story-video" id="video-player" playsinline loop>
<source src="动态绘本/失控的魔法扫帚.mp4" type="video/mp4">
您的浏览器不支持视频标签。
</video>
<!-- Click Overlay -->
<div class="video-overlay" id="video-overlay">
<div class="play-btn-circle" id="overlay-icon">
<!-- Play Icon -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
<!-- Fullscreen Button -->
<button class="fullscreen-btn" id="fs-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Generator Mode Footer (New Story) -->
<div class="generator-footer" id="footer-gen">
<div class="btn-group-2col">
<button class="btn-secondary" onclick="window.location.href='story-loading.html'">
<span>↻ 重写</span>
</button>
<button class="btn-capybara-primary" id="save-btn-magic">
<span>保存故事</span>
</button>
</div>
</div>
<!-- Read Mode Footer: Text Tab -->
<div class="generator-footer" id="footer-text-mode" style="display: none;">
<div class="btn-group-2col">
<button class="btn-secondary" id="tts-btn"
style="height: 50px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px;">
<span id="tts-icon" style="display: flex;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path>
<path
d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z">
</path>
</svg>
</span>
<span id="tts-text">朗读</span>
</button>
<button class="btn-capybara-primary" id="make-book-btn">
<span id="make-book-text">变绘本</span>
</button>
</div>
</div>
<!-- Read Mode Footer: Video Tab -->
<div class="generator-footer" id="footer-video-mode" style="display: none;">
<!-- Single Full Width Button for Regen -->
<button class="btn-capybara-primary" id="regen-video-btn" style="width:100%; justify-content:center;">
<span>↻ 重新生成</span>
</button>
</div>
<script>
// Mock Stories for Testing
const mockStories = [
{
title: "星际忍者的茶话会",
content: `
<p>在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。</p>
<p>“这儿的重力好像有点不对劲?”波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。</p>
<p>突然,桌上的魔法茶壶“噗”地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:“别打架,别打架,喝了这杯‘银河气泡茶’,我们都是好朋友!”</p>
<p>于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。</p>
`
},
{
title: "卡皮巴拉的午睡奇遇",
content: `
<p>午后的阳光洒在亚马逊河畔,一只名叫卡卡的水豚正躺在巨大的睡莲叶上打盹。它的鼻子上停着一只蓝色的蜻蜓,而它的肚子上,竟然正在举行一场蚂蚁足球赛。</p>
<p>“嘿!那是犯规!”一只穿着草裙的松鼠裁判吹响了哨子。卡卡迷迷糊糊地睁开眼,发现周围聚集了一圈森林里的小动物,连平时最害羞的树懒都慢慢爬下来围观。</p>
<p>原来,卡卡睡觉时发出的呼噜声,正好是一首完美的桑巴舞曲。小动物们忍不住跟着节奏扭动起来。卡卡打了个哈欠,心想:“既然大家都这么开心,那我再睡五分钟吧。”</p>
<p>就这样,森林里最盛大的午后舞会,在一只水豚的梦境中拉开了帷幕。</p>
`
},
{
title: "失控的魔法扫帚",
content: `
<p>魔法学院的期末考试正在进行中小女巫艾米紧张地握着她的新扫帚“光轮2026”。考试题目是平稳飞越学校的钟楼并且不撞到任何一只鸽子。</p>
<p>“起飞!”艾米念出咒语。可是,扫帚似乎有了自己的想法,它没有飞向钟楼,而是像火箭一样冲向了食堂的窗户!</p>
<p>“糟糕!那是校长的草莓蛋糕!”艾米惊呼。就在千钧一发之际,扫帚突然一个急转弯,稳稳地停在了蛋糕前——原来它只是饿了。</p>
<p>虽然考试不及格,但艾米发明了全校最快的“外卖配送术”。从此以后,魔法学院的学生们再也不用担心吃不到热乎乎的披萨了。</p>
`
}
];
let hasGeneratedVideo = false;
// --- Custom Modal Helpers ---
function showCustomConfirm(message, onConfirm) {
const modal = document.getElementById('custom-modal');
const title = document.getElementById('modal-title');
const desc = document.getElementById('modal-desc');
const cancelBtn = document.getElementById('modal-cancel');
const confirmBtn = document.getElementById('modal-confirm');
title.innerText = "魔法确认";
desc.innerHTML = message; // Using innerHTML for <br> support
modal.classList.add('active');
// Clean up old listeners to avoid multiple triggers
const newConfirm = confirmBtn.cloneNode(true);
const newCancel = cancelBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(newConfirm, confirmBtn);
cancelBtn.parentNode.replaceChild(newCancel, cancelBtn);
newConfirm.addEventListener('click', () => {
modal.classList.remove('active');
if (onConfirm) onConfirm();
});
newCancel.addEventListener('click', () => {
modal.classList.remove('active');
});
}
function showCustomToast(message) {
const toast = document.getElementById('custom-toast');
toast.innerText = message;
toast.classList.add('active');
setTimeout(() => {
toast.classList.remove('active');
}, 2000);
}
// Global function for tab switching
window.switchContentTab = function (type) {
const textView = document.getElementById('view-text');
const videoView = document.getElementById('view-video');
const btnText = document.getElementById('tab-text');
const btnVideo = document.getElementById('tab-video');
const video = document.getElementById('video-player');
const card = document.getElementById('story-card');
// Footer Elements
const footerText = document.getElementById('footer-text-mode');
const footerVideo = document.getElementById('footer-video-mode');
if (type === 'text') {
textView.style.display = 'block';
videoView.style.display = 'none';
btnText.classList.add('active');
btnVideo.classList.remove('active');
video.pause();
updateOverlayIcon(false);
// RESTORE TEXT STYLE: Remove video-mode class
card.classList.remove('video-mode');
// Update Button Text if video exists
if (hasGeneratedVideo) {
const makeBookText = document.getElementById('make-book-text');
makeBookText.innerText = "重新生成";
}
// Show Text Footer Logic
footerText.style.display = 'flex';
footerVideo.style.display = 'none';
} else {
textView.style.display = 'none';
videoView.style.display = 'block';
btnText.classList.remove('active');
btnVideo.classList.add('active');
// FIX: Do NOT auto-play on simple switch. Only update icon state.
updateOverlayIcon(!video.paused);
// Show Video Footer & Toggle Maximize Mode
card.classList.add('video-mode'); // Removes padding/shadow/background
footerText.style.display = 'none';
footerVideo.style.display = 'flex';
}
};
function updateOverlayIcon(isPlaying) {
const overlay = document.getElementById('video-overlay');
const icon = document.getElementById('overlay-icon');
if (isPlaying) {
overlay.classList.add('hidden');
} else {
overlay.classList.remove('hidden');
icon.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
`;
}
}
document.addEventListener('DOMContentLoaded', () => {
// Check Current Index from LocalStorage
let index = parseInt(localStorage.getItem('story_test_index') || '0');
// --- Check ID Param Override ---
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('id')) {
index = parseInt(urlParams.get('id'));
} else if (document.referrer.includes('story-loading.html')) {
// Only increment if coming from new generation and NO ID param
index = (index + 1) % mockStories.length;
localStorage.setItem('story_test_index', index);
}
// Safety check
if (index >= mockStories.length) index = 0;
const story = mockStories[index];
// Render
document.querySelector('.header-title').innerText = story.title;
const contentEl = document.querySelector('.story-content');
contentEl.innerHTML = story.content;
// --- Video Logic ---
const video = document.getElementById('video-player');
const overlay = document.getElementById('video-overlay');
// Video Click Handler
overlay.addEventListener('click', (e) => {
const videoEl = document.getElementById('video-player');
if (videoEl.paused) {
videoEl.play();
updateOverlayIcon(true);
} else {
videoEl.pause();
updateOverlayIcon(false);
}
});
video.addEventListener('click', () => {
const videoEl = document.getElementById('video-player');
if (!videoEl.paused) {
videoEl.pause();
updateOverlayIcon(false);
}
});
// Fullscreen Handler
const fsBtn = document.getElementById('fs-btn');
fsBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent toggling play/pause
const videoEl = document.getElementById('video-player');
if (videoEl.requestFullscreen) {
videoEl.requestFullscreen();
} else if (videoEl.webkitEnterFullscreen) {
videoEl.webkitEnterFullscreen(); // iOS Safari
} else if (videoEl.mozRequestFullScreen) {
videoEl.mozRequestFullScreen();
} else if (videoEl.msRequestFullscreen) {
videoEl.msRequestFullscreen();
}
});
// --- Mode Handling ---
const mode = urlParams.get('mode');
if (mode === 'read') {
// Hide Generator Footer
document.getElementById('footer-gen').style.display = 'none';
// Show Initial Text Footer
document.getElementById('footer-text-mode').style.display = 'flex';
// --- TTS Logic ---
const ttsBtn = document.getElementById('tts-btn');
const ttsIcon = document.getElementById('tts-icon');
const ttsText = document.getElementById('tts-text');
let utterance = null;
// Stop any previous speech
window.speechSynthesis.cancel();
function setPauseUI() {
// Pause Icon
ttsIcon.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>`;
ttsText.innerText = '暂停';
ttsBtn.classList.add('playing');
}
function setPlayUI() {
// Play Icon
ttsIcon.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`;
ttsText.innerText = '继续';
ttsBtn.classList.remove('playing');
}
function resetTTSUI() {
// Headset Icon
ttsIcon.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"></path><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path></svg>`;
ttsText.innerText = '朗读';
ttsBtn.classList.remove('playing');
}
ttsBtn.onclick = () => {
const synth = window.speechSynthesis;
if (!utterance) {
const pureText = contentEl.innerText;
utterance = new SpeechSynthesisUtterance(pureText);
utterance.lang = 'zh-CN';
utterance.rate = 1.0;
utterance.onend = () => {
utterance = null;
resetTTSUI();
};
synth.speak(utterance);
setPauseUI();
} else {
if (synth.paused) {
synth.resume();
setPauseUI();
} else {
if (synth.speaking) {
synth.pause();
setPlayUI();
} else {
utterance = null;
ttsBtn.click(); // Restart
}
}
}
};
// --- Helper: Simulate Generation ---
function runGenerationProcess() {
const loader = document.getElementById('video-loading-state');
const videoWrapper = document.getElementById('video-content-wrapper');
// Show Tabs & Switch to Video Mode
document.getElementById('content-tabs').style.display = 'flex';
hasGeneratedVideo = true;
switchContentTab('video');
// 1. Show Loading State, Hide Content
loader.style.display = 'flex';
videoWrapper.style.display = 'none';
// 2. Mock Wait
setTimeout(() => {
// 3. Swap to Content
loader.style.display = 'none';
videoWrapper.style.display = 'block';
// Auto-play ONLY on fresh generation
const video = document.getElementById('video-player');
video.play().catch(e => console.log("Autoplay failed", e));
updateOverlayIcon(true);
}, 2500);
}
// --- Make Picture Book Logic ---
const makeBookBtn = document.getElementById('make-book-btn');
makeBookBtn.onclick = () => {
// STOP TTS IF PLAYING
if (window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
resetTTSUI();
}
const actionText = hasGeneratedVideo
? "重新生成将消耗 10 SP<br>确定要继续吗?"
: "生成动态绘本将消耗 10 SP<br>确定要继续吗?";
showCustomConfirm(actionText, () => {
runGenerationProcess();
});
};
// --- Regenerate Video Logic ---
const regenBtn = document.getElementById('regen-video-btn');
regenBtn.onclick = () => {
// Force Video Pause immediately
const video = document.getElementById('video-player');
video.pause();
updateOverlayIcon(false);
showCustomConfirm("重新生成将消耗 10 SP<br>确定要继续吗?", () => {
runGenerationProcess();
});
};
} else {
// Default: Generator Mode (Magic Save)
const saveBtn = document.getElementById('save-btn-magic');
if (saveBtn) {
saveBtn.addEventListener('click', (e) => {
const card = document.querySelector('.story-paper');
if (!card) return;
const btnRect = saveBtn.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const cardCenterX = cardRect.left + cardRect.width / 2;
const cardCenterY = cardRect.top + cardRect.height / 2;
const btnCenterX = btnRect.left + btnRect.width / 2;
const btnCenterY = btnRect.top + btnRect.height / 2;
const tx = btnCenterX - cardCenterX;
const ty = btnCenterY - cardCenterY;
card.style.setProperty('--tx', `${tx}px`);
card.style.setProperty('--ty', `${ty}px`);
card.classList.add('genie-effect');
setTimeout(() => {
window.location.href = 'device-control.html?tab=story&magic=true';
}, 800);
});
}
}
});
</script>
</body>
</html>