- 接入豆包TTS V1 WebSocket API,支持故事朗读语音合成 - 新增 PillProgressButton 组件(药丸形进度按钮) - 新增 TTSService 单例,后台生成不中断 - 音频保存到 Capybara audio/ 目录 - 唱片架当前播放歌曲高亮(金色卡片+音波动效+喇叭图标) - 播放时气泡持续显示当前歌名,暂停后隐藏 - 音乐总监Prompt去固定模板,歌名不再重复 - 新增 API 参考文档(豆包语音合成) Co-authored-by: Cursor <cursoragent@cursor.com>
654 lines
18 KiB
Dart
654 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import '../theme/design_tokens.dart';
|
|
import '../widgets/gradient_button.dart';
|
|
import '../widgets/pill_progress_button.dart';
|
|
import '../services/tts_service.dart';
|
|
import 'story_loading_page.dart';
|
|
|
|
enum StoryMode { generated, read }
|
|
|
|
class StoryDetailPage extends StatefulWidget {
|
|
final Map<String, dynamic>? story; // Pass story object
|
|
final StoryMode mode;
|
|
|
|
const StoryDetailPage({
|
|
super.key,
|
|
this.story,
|
|
this.mode = StoryMode.read, // Default: Read mode (from HTML logic)
|
|
});
|
|
|
|
@override
|
|
State<StoryDetailPage> createState() => _StoryDetailPageState();
|
|
}
|
|
|
|
class _StoryDetailPageState extends State<StoryDetailPage>
|
|
with SingleTickerProviderStateMixin {
|
|
// Tab State
|
|
String _activeTab = 'text'; // 'text' or 'video'
|
|
bool _isPlaying = false;
|
|
bool _hasGeneratedVideo = false;
|
|
bool _isLoadingVideo = false;
|
|
|
|
// TTS — uses global TTSService singleton
|
|
final TTSService _ttsService = TTSService.instance;
|
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
|
StreamSubscription<Duration>? _positionSub;
|
|
StreamSubscription<PlayerState>? _playerStateSub;
|
|
Duration _audioDuration = Duration.zero;
|
|
Duration _audioPosition = Duration.zero;
|
|
|
|
// Genie Suck Animation
|
|
bool _isSaving = false;
|
|
AnimationController? _genieController;
|
|
double _targetDY = 250; // fallback, recalculated at runtime
|
|
|
|
// Mock Content from HTML
|
|
final Map<String, dynamic> _defaultStory = {
|
|
'title': "星际忍者的茶话会",
|
|
'content': """
|
|
在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。
|
|
|
|
"这儿的重力好像有点不对劲?"波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。
|
|
|
|
突然,桌上的魔法茶壶"噗"地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:"别打架,别打架,喝了这杯'银河气泡茶',我们都是好朋友!"
|
|
|
|
于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。
|
|
""",
|
|
};
|
|
|
|
late Map<String, dynamic> _currentStory;
|
|
|
|
Map<String, dynamic> _initStory() {
|
|
final source = widget.story ?? _defaultStory;
|
|
final result = Map<String, dynamic>.from(source);
|
|
result['content'] ??= _defaultStory['content'];
|
|
result['title'] ??= _defaultStory['title'];
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_currentStory = _initStory();
|
|
|
|
// Subscribe to TTSService changes
|
|
_ttsService.addListener(_onTTSChanged);
|
|
|
|
// Listen to audio player state
|
|
_playerStateSub = _audioPlayer.playerStateStream.listen((state) {
|
|
if (!mounted) return;
|
|
if (state.processingState == ProcessingState.completed) {
|
|
setState(() {
|
|
_isPlaying = false;
|
|
_audioPosition = Duration.zero;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Listen to playback position for ring progress
|
|
_positionSub = _audioPlayer.positionStream.listen((pos) {
|
|
if (!mounted) return;
|
|
setState(() => _audioPosition = pos);
|
|
});
|
|
|
|
// Listen to duration changes
|
|
_audioPlayer.durationStream.listen((dur) {
|
|
if (!mounted || dur == null) return;
|
|
setState(() => _audioDuration = dur);
|
|
});
|
|
|
|
// Check if audio already exists (via TTSService)
|
|
final title = _currentStory['title'] as String? ?? '';
|
|
_ttsService.checkExistingAudio(title);
|
|
}
|
|
|
|
void _onTTSChanged() {
|
|
if (!mounted) return;
|
|
|
|
// Auto-play when generation completes
|
|
if (_ttsService.justCompleted &&
|
|
_ttsService.hasAudioFor(_currentStory['title'] ?? '')) {
|
|
// Delay slightly to let the completion flash play
|
|
Future.delayed(const Duration(milliseconds: 1500), () {
|
|
if (mounted) {
|
|
_ttsService.clearJustCompleted();
|
|
final route = ModalRoute.of(context);
|
|
if (route != null && route.isCurrent) {
|
|
_playAudio();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_ttsService.removeListener(_onTTSChanged);
|
|
_positionSub?.cancel();
|
|
_playerStateSub?.cancel();
|
|
_audioPlayer.dispose();
|
|
_genieController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// ── TTS button logic ──
|
|
|
|
bool _audioLoaded = false; // Track if audio URL is loaded in player
|
|
String? _loadedUrl; // Which URL is currently loaded
|
|
|
|
TTSButtonState get _ttsState {
|
|
final title = _currentStory['title'] as String? ?? '';
|
|
|
|
if (_ttsService.error != null &&
|
|
!_ttsService.isGenerating &&
|
|
_ttsService.audioUrl == null) {
|
|
return TTSButtonState.error;
|
|
}
|
|
if (_ttsService.isGeneratingFor(title)) {
|
|
return TTSButtonState.generating;
|
|
}
|
|
if (_ttsService.justCompleted && _ttsService.hasAudioFor(title)) {
|
|
return TTSButtonState.completed;
|
|
}
|
|
if (_isPlaying) {
|
|
return TTSButtonState.playing;
|
|
}
|
|
if (_ttsService.hasAudioFor(title) && !_audioLoaded) {
|
|
return TTSButtonState.ready; // audio ready, not yet played -> show "鎾斁"
|
|
}
|
|
if (_audioLoaded) {
|
|
return TTSButtonState.paused; // was playing, now paused -> show "缁х画"
|
|
}
|
|
return TTSButtonState.idle;
|
|
}
|
|
|
|
double get _ttsProgress {
|
|
final state = _ttsState;
|
|
switch (state) {
|
|
case TTSButtonState.generating:
|
|
return _ttsService.progress;
|
|
case TTSButtonState.ready:
|
|
return 0.0;
|
|
case TTSButtonState.completed:
|
|
return 1.0;
|
|
case TTSButtonState.playing:
|
|
case TTSButtonState.paused:
|
|
if (_audioDuration.inMilliseconds > 0) {
|
|
return (_audioPosition.inMilliseconds / _audioDuration.inMilliseconds)
|
|
.clamp(0.0, 1.0);
|
|
}
|
|
return 0.0;
|
|
default:
|
|
return 0.0;
|
|
}
|
|
}
|
|
|
|
void _handleTTSTap() {
|
|
final state = _ttsState;
|
|
switch (state) {
|
|
case TTSButtonState.idle:
|
|
case TTSButtonState.error:
|
|
final title = _currentStory['title'] as String? ?? '';
|
|
final content = _currentStory['content'] as String? ?? '';
|
|
_ttsService.generate(title: title, content: content);
|
|
break;
|
|
case TTSButtonState.generating:
|
|
break;
|
|
case TTSButtonState.ready:
|
|
case TTSButtonState.completed:
|
|
case TTSButtonState.paused:
|
|
_playAudio();
|
|
break;
|
|
case TTSButtonState.playing:
|
|
_audioPlayer.pause();
|
|
setState(() => _isPlaying = false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _playAudio() async {
|
|
final title = _currentStory['title'] as String? ?? '';
|
|
final url = _ttsService.hasAudioFor(title) ? _ttsService.audioUrl : null;
|
|
if (url == null) return;
|
|
|
|
try {
|
|
// If already loaded the same URL, seek to saved position and resume
|
|
if (_audioLoaded && _loadedUrl == url) {
|
|
await _audioPlayer.seek(_audioPosition);
|
|
_audioPlayer.play();
|
|
} else {
|
|
// Load new URL and play from start
|
|
await _audioPlayer.setUrl(url);
|
|
_audioLoaded = true;
|
|
_loadedUrl = url;
|
|
_audioPlayer.play();
|
|
}
|
|
if (mounted) {
|
|
setState(() => _isPlaying = true);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Audio play error: $e');
|
|
}
|
|
}
|
|
|
|
// ── Genie Suck Animation ──
|
|
|
|
void _triggerGenieSuck() {
|
|
if (_isSaving) return;
|
|
|
|
_genieController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 800),
|
|
);
|
|
|
|
final screenHeight = MediaQuery.of(context).size.height;
|
|
_targetDY = screenHeight * 0.35;
|
|
|
|
_genieController!.addStatusListener((status) {
|
|
if (status == AnimationStatus.completed && mounted) {
|
|
Navigator.of(context).pop('saved');
|
|
}
|
|
});
|
|
|
|
setState(() => _isSaving = true);
|
|
_genieController!.forward();
|
|
}
|
|
|
|
// ── Build ──
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppColors.storyBackground,
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Expanded(child: _buildAnimatedBody()),
|
|
_buildFooter(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAnimatedBody() {
|
|
Widget body = Column(
|
|
children: [
|
|
_buildHeader(),
|
|
if (_hasGeneratedVideo || _isLoadingVideo) _buildTabSwitcher(),
|
|
Expanded(child: _buildContentCard()),
|
|
],
|
|
);
|
|
|
|
if (_isSaving && _genieController != null) {
|
|
return AnimatedBuilder(
|
|
animation: _genieController!,
|
|
builder: (context, child) {
|
|
final t = _genieController!.value;
|
|
|
|
double scale;
|
|
double translateY;
|
|
double opacity;
|
|
double blur;
|
|
|
|
if (t <= 0.15) {
|
|
final p = t / 0.15;
|
|
scale = 1.0 + 0.05 * Curves.easeOut.transform(p);
|
|
translateY = 0;
|
|
opacity = 1.0;
|
|
blur = 0;
|
|
} else {
|
|
final p = ((t - 0.15) / 0.85).clamp(0.0, 1.0);
|
|
final curved =
|
|
const Cubic(0.6, -0.28, 0.735, 0.045).transform(p);
|
|
scale = 1.05 - 1.0 * curved;
|
|
translateY = _targetDY * curved;
|
|
opacity = 1.0 - curved;
|
|
blur = 8.0 * curved;
|
|
}
|
|
|
|
return Transform.translate(
|
|
offset: Offset(0, translateY),
|
|
child: Transform.scale(
|
|
scale: scale.clamp(0.01, 1.5),
|
|
child: Opacity(
|
|
opacity: opacity.clamp(0.0, 1.0),
|
|
child: blur > 0.5
|
|
? ImageFiltered(
|
|
imageFilter: ui.ImageFilter.blur(
|
|
sigmaX: blur,
|
|
sigmaY: blur,
|
|
),
|
|
child: child,
|
|
)
|
|
: child,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: body,
|
|
);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: GestureDetector(
|
|
onTap: () => Navigator.of(context).pop(),
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.6),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Icon(
|
|
Icons.arrow_back_ios_new,
|
|
size: 18,
|
|
color: Color(0xFF4B5563),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
_currentStory['title'] ?? '',
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF1F2937),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTabSwitcher() {
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildTabBtn('故事', 'text'),
|
|
const SizedBox(width: 8),
|
|
_buildTabBtn('绘本', 'video'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTabBtn(String label, String key) {
|
|
bool isActive = _activeTab == key;
|
|
return GestureDetector(
|
|
onTap: () => setState(() => _activeTab = key),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: isActive ? Colors.white : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: isActive
|
|
? [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: isActive ? AppColors.storyTitle : AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContentCard() {
|
|
bool isVideoMode = _activeTab == 'video';
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.6),
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
|
child: isVideoMode ? _buildVideoView() : _buildTextView(),
|
|
);
|
|
}
|
|
|
|
Widget _buildTextView() {
|
|
return SingleChildScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
child: Text(
|
|
_currentStory['content']
|
|
.toString()
|
|
.replaceAll(RegExp(r'\n+'), '\n\n')
|
|
.trim(),
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
height: 2.0,
|
|
color: AppColors.storyText,
|
|
),
|
|
textAlign: TextAlign.justify,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVideoView() {
|
|
if (_isLoadingVideo) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: CircularProgressIndicator(
|
|
color: Color(0xFFF43F5E),
|
|
strokeWidth: 3,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'AI 正在绘制动态绘本...',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF4B5563),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'消耗 10 SP',
|
|
style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: Container(
|
|
color: Colors.black,
|
|
child: const Center(
|
|
child: Icon(Icons.videocam, color: Colors.white54, size: 48),
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.8),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.play_arrow, color: Colors.black),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildFooter() {
|
|
Widget footer = Container(
|
|
padding: EdgeInsets.fromLTRB(
|
|
24,
|
|
8,
|
|
24,
|
|
MediaQuery.of(context).padding.bottom + 8,
|
|
),
|
|
child: _activeTab == 'text' ? _buildTextFooter() : _buildVideoFooter(),
|
|
);
|
|
|
|
if (_isSaving) {
|
|
return IgnorePointer(
|
|
child: AnimatedOpacity(
|
|
opacity: 0.0,
|
|
duration: const Duration(milliseconds: 300),
|
|
child: footer,
|
|
),
|
|
);
|
|
}
|
|
|
|
return footer;
|
|
}
|
|
|
|
void _handleRewrite() async {
|
|
final result = await Navigator.of(context).push<String>(
|
|
MaterialPageRoute(builder: (context) => const StoryLoadingPage()),
|
|
);
|
|
if (mounted && result == 'saved') {
|
|
Navigator.of(context).pop('saved');
|
|
}
|
|
}
|
|
|
|
Widget _buildTextFooter() {
|
|
if (widget.mode == StoryMode.generated) {
|
|
// Generator Mode: Rewrite + Save
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: _handleRewrite,
|
|
child: Container(
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
borderRadius: BorderRadius.circular(24),
|
|
color: Colors.white.withOpacity(0.8),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.refresh_rounded, size: 18, color: Color(0xFF4B5563)),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'重写',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF4B5563),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: GradientButton(
|
|
text: '保存故事',
|
|
onPressed: _triggerGenieSuck,
|
|
gradient: const LinearGradient(
|
|
colors: AppColors.btnCapybaraGradient,
|
|
),
|
|
height: 48,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
} else {
|
|
// Read Mode: TTS pill button + Make Picture Book
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: PillProgressButton(
|
|
state: _ttsState,
|
|
progress: _ttsProgress,
|
|
onTap: _handleTTSTap,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
// Make Picture Book
|
|
Expanded(
|
|
child: GradientButton(
|
|
text: '变绘本',
|
|
onPressed: _startVideoGeneration,
|
|
gradient: const LinearGradient(
|
|
colors: AppColors.btnCapybaraGradient,
|
|
),
|
|
height: 48,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildVideoFooter() {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: GradientButton(
|
|
text: '重新生成',
|
|
onPressed: _startVideoGeneration,
|
|
gradient: const LinearGradient(
|
|
colors: AppColors.btnCapybaraGradient,
|
|
),
|
|
height: 48,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _startVideoGeneration() {
|
|
setState(() {
|
|
_isLoadingVideo = true;
|
|
_activeTab = 'video';
|
|
});
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoadingVideo = false;
|
|
_hasGeneratedVideo = true;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|