import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show PlatformException; import 'package:just_audio/just_audio.dart'; import 'package:video_player/video_player.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? 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 createState() => _StoryDetailPageState(); } class _StoryDetailPageState extends State with SingleTickerProviderStateMixin { // Tab State String _activeTab = 'text'; // 'text' or 'video' bool _isPlaying = false; bool _hasGeneratedVideo = false; bool _isLoadingVideo = false; // Video Player VideoPlayerController? _videoController; bool _videoInitialized = false; // TTS — uses global TTSService singleton final TTSService _ttsService = TTSService.instance; final AudioPlayer _audioPlayer = AudioPlayer(); StreamSubscription? _positionSub; StreamSubscription? _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 _defaultStory = { 'title': "星际忍者的茶话会", 'content': """ 在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。 "这儿的重力好像有点不对劲?"波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。 突然,桌上的魔法茶壶"噗"地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:"别打架,别打架,喝了这杯'银河气泡茶',我们都是好朋友!" 于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。 """, }; late Map _currentStory; Map _initStory() { final source = widget.story ?? _defaultStory; final result = Map.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; }); } }, onError: (e) { debugPrint('playerStateStream error (ignored): $e'); }); // Listen to playback position for ring progress _positionSub = _audioPlayer.positionStream.listen((pos) { if (!mounted) return; setState(() => _audioPosition = pos); }, onError: (e) { debugPrint('positionStream error (ignored): $e'); }); // Listen to duration changes _audioPlayer.durationStream.listen((dur) { if (!mounted || dur == null) return; setState(() => _audioDuration = dur); }, onError: (e) { debugPrint('durationStream error (ignored): $e'); }); // Auto-show video tab if story already has a video final hasVideo = _currentStory['has_video'] == true; final videoUrl = _currentStory['video_url'] as String? ?? ''; if (hasVideo && videoUrl.isNotEmpty) { _hasGeneratedVideo = true; _activeTab = 'video'; _initVideoPlayer(videoUrl); } // Check if audio already exists debugPrint('[StoryDetail] story keys: ${_currentStory.keys.toList()}'); debugPrint('[StoryDetail] audio_url value: "${_currentStory['audio_url']}"'); debugPrint('[StoryDetail] id value: ${_currentStory['id']}'); final title = _currentStory['title'] as String? ?? ''; final audioUrl = _currentStory['audio_url'] as String? ?? ''; final storyId = _currentStory['id'] as int?; debugPrint('[StoryDetail] parsed: title=$title, audioUrl=$audioUrl, storyId=$storyId'); if (audioUrl.isNotEmpty) { debugPrint('[StoryDetail] -> setExistingAudio'); _ttsService.setExistingAudio(title, audioUrl); } else if (storyId != null) { debugPrint('[StoryDetail] -> checkExistingAudio'); _ttsService.checkExistingAudio(title, storyId: storyId); } else { debugPrint('[StoryDetail] -> no audio, no storyId'); } } 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(() {}); } Future _initVideoPlayer(String url) async { try { final controller = VideoPlayerController.networkUrl(Uri.parse(url)); _videoController = controller; controller.addListener(_onVideoChanged); await controller.initialize(); if (mounted) { setState(() => _videoInitialized = true); } } catch (e) { debugPrint('Video init error: $e'); } } void _onVideoChanged() { if (!mounted) return; setState(() {}); } @override void dispose() { _ttsService.removeListener(_onTTSChanged); _positionSub?.cancel(); _playerStateSub?.cancel(); _audioPlayer.dispose(); _videoController?.removeListener(_onVideoChanged); _videoController?.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 && _ttsService.errorTitle == title) { 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? ?? ''; final storyId = _currentStory['id'] as int? ?? 0; _ttsService.generate(title: title, content: content, storyId: storyId); 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 _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); } } on PlatformException catch (e) { // PlatformException(abort) is expected when loading is interrupted if (e.code == 'abort') { debugPrint('Audio load interrupted (expected): $e'); return; } debugPrint('Audio play error: $e'); } 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)), ), ], ), ); } // Not yet initialized — black + spinner while video loads if (!_videoInitialized || _videoController == null) { return const AspectRatio( aspectRatio: 16 / 9, child: ColoredBox( color: Colors.black, child: Center( child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 3), ), ), ); } // Initialized: VideoPlayer shows frame 0 naturally as thumbnail when paused final isPlaying = _videoController!.value.isPlaying; return AspectRatio( aspectRatio: _videoController!.value.aspectRatio, child: Stack( alignment: Alignment.center, children: [ // Video fills the area (Positioned.fill avoids StackFit.expand distortion) Positioned.fill(child: VideoPlayer(_videoController!)), // Full-area tap handler Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { if (_videoController!.value.isPlaying) { await _videoController!.pause(); } else { await _videoController!.play(); } if (mounted) setState(() {}); }, ), ), // Play button — IgnorePointer lets taps pass through to GestureDetector below if (!isPlaying) IgnorePointer( child: 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( 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; }); } }); } }