import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; import '../../utils/server_config.dart'; import '../../utils/user_prefs.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; import '../../utils/image_path_helper.dart'; import '../../utils/toast_util.dart'; import '../../widgets/app_overlay.dart'; /// 影视详情页 - 在线版 class MovieDetailPage extends StatefulWidget { final int vodId; const MovieDetailPage({super.key, required this.vodId}); @override State createState() => _MovieDetailPageState(); } class _MovieDetailPageState extends State with WidgetsBindingObserver { Map? _data; List> _staffList = []; bool _loading = true; bool _staffLoading = false; String? _error; bool _expanded = false; Movie? _localMovie; int _currentTab = 0; int _detailStyle = 0; // 0: 紧凑, 1: 沉浸式 // 播放 Tab 数据 bool _playLoading = false; String? _playError; List _playSources = []; List> _playEpisodes = []; Set _playedEpisodes = {}; int _currentSource = 0; int? _currentEpisode; // 播放器状态 bool _isPlaying = false; Player? _player; VideoController? _videoController; bool _playerCreating = false; bool _isFullscreen = false; // 播放器实时状态 Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _isPlayerPlaying = false; bool _isBuffering = false; double _rate = 1.0; bool _showControls = true; bool _isSeeking = false; bool _isLongPress2x = false; final List _playerSubs = []; Timer? _hideControlsTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _load(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _hideControlsTimer?.cancel(); for (final sub in _playerSubs) { sub.cancel(); } _playerSubs.clear(); _player?.dispose(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused) { _player?.pause(); } } Future _load() async { final token = UserPrefs().movieSearchToken; try { final url = '${ServerConfig.vipBaseUrl}/api/movie/detail?vodId=${widget.vodId}&token=$token'; final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); if (!mounted) return; if (resp.statusCode == 200) { final json_ = json.decode(resp.body); if (json_['code'] == 0 && json_['data'] != null) { setState(() { _data = json_['data']; _loading = false; }); _loadStaff(); _checkLocal(); return; } } setState(() { _error = '加载失败'; _loading = false; }); } catch (_) { if (mounted) setState(() { _error = '网络错误'; _loading = false; }); } } Future _loadStaff() async { final staffStr = _data?['vod_staff'] ?? ''; if (staffStr.toString().isEmpty) return; final token = UserPrefs().movieSearchToken; setState(() { _staffLoading = true; }); try { final url = '${ServerConfig.vipBaseUrl}/api/actor/staff-pic?token=$token'; final resp = await http.post(Uri.parse(url), body: staffStr.toString(), headers: { 'Content-Type': 'application/json' }).timeout(const Duration(seconds: 10)); if (!mounted) return; if (resp.statusCode == 200) { final json_ = json.decode(resp.body); if (json_['code'] == 0 && json_['data'] != null) { setState(() { _staffList = (json_['data'] as List) .map((e) => e as Map) .toList(); _staffLoading = false; }); return; } } } catch (_) {} if (mounted) setState(() { _staffLoading = false; }); } Future _loadPlayInfo() async { if (_playSources.isNotEmpty || _playLoading) return; final token = UserPrefs().movieSearchToken; setState(() { _playLoading = true; _playError = null; }); try { final url = '${ServerConfig.vipBaseUrl}/api/movie/detail/plus?vodId=${widget.vodId}&token=$token'; final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); if (!mounted) return; if (resp.statusCode == 200) { final json_ = json.decode(resp.body); if (json_['code'] == 0 && json_['data'] != null) { final data = json_['data'] as Map; final sources = (data['vod_play_from'] ?? '') .toString() .split(r'$$$') .where((s) => s.trim().isNotEmpty) .toList(); final urlGroups = (data['vod_play_url'] ?? '') .toString() .split(r'$$$'); final episodes = >[]; for (var i = 0; i < sources.length; i++) { final group = i < urlGroups.length ? urlGroups[i] : ''; final eps = <({String name, String url})>[]; for (final raw in group.split('#')) { final parts = raw.split('\$'); if (parts.length >= 2 && parts[1].trim().isNotEmpty) { eps.add((name: parts[0].trim(), url: parts[1].trim())); } } episodes.add(eps); } if (mounted) { setState(() { _playSources = sources; _playEpisodes = episodes; _playedEpisodes = UserPrefs().getPlayedEpisodes(widget.vodId).toSet(); _playLoading = false; }); } return; } } if (mounted) { setState(() { _playError = '加载失败'; _playLoading = false; }); } } catch (_) { if (mounted) { setState(() { _playError = '网络错误'; _playLoading = false; }); } } } Future _startPlay(String url, int episodeIndex) async { if (_playerCreating) return; setState(() { _playerCreating = true; }); try { if (_player == null) { _player = Player(); _videoController = VideoController(_player!); _subscribePlayerStreams(); } await _player!.open(Media(url)); // 切集后恢复倍速 if (_rate != 1.0) { await _player!.setRate(_rate); } if (!mounted) return; final epName = _playEpisodes[_currentSource][episodeIndex].name; await UserPrefs().addPlayedEpisode(widget.vodId, epName); setState(() { _isPlaying = true; _currentEpisode = episodeIndex; _playedEpisodes.add(epName); _playerCreating = false; _position = Duration.zero; _duration = Duration.zero; _showControls = true; _isLongPress2x = false; }); _restartHideControlsTimer(); } catch (_) { if (mounted) { ToastUtil.show(context, '播放失败'); setState(() { _playerCreating = false; }); } } } void _subscribePlayerStreams() { final p = _player!; _playerSubs.add(p.stream.playing.listen((v) { if (mounted) setState(() => _isPlayerPlaying = v); })); _playerSubs.add(p.stream.position.listen((v) { if (mounted && !_isSeeking) setState(() => _position = v); })); _playerSubs.add(p.stream.duration.listen((v) { if (mounted) setState(() => _duration = v); })); _playerSubs.add(p.stream.buffering.listen((v) { if (mounted) setState(() => _isBuffering = v); })); _playerSubs.add(p.stream.rate.listen((v) { if (mounted) setState(() => _rate = v); })); _playerSubs.add(p.stream.completed.listen((completed) { if (completed && mounted) _onPlayCompleted(); })); } void _onPlayCompleted() { // 自动播放下一集 final episodes = _playEpisodes[_currentSource]; if (_currentEpisode == null) return; if (_currentEpisode! < episodes.length - 1) { _startPlay(episodes[_currentEpisode! + 1].url, _currentEpisode! + 1); } else { // 末集,退出播放 setState(() { _isPlaying = false; _isPlayerPlaying = false; }); } } void _playPrevEpisode() { if (_currentEpisode == null || _currentEpisode! <= 0) return; final episodes = _playEpisodes[_currentSource]; _startPlay(episodes[_currentEpisode! - 1].url, _currentEpisode! - 1); } void _playNextEpisode() { if (_currentEpisode == null) return; final episodes = _playEpisodes[_currentSource]; if (_currentEpisode! >= episodes.length - 1) return; _startPlay(episodes[_currentEpisode! + 1].url, _currentEpisode! + 1); } Future _showRateSheet() async { final colors = Theme.of(context).colorScheme; final rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; final selected = await appModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), child: Column(mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 14), decoration: BoxDecoration( color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))), Text('播放速度', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: rates .map((r) => InkWell( onTap: () => Navigator.pop(ctx, r), borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 10), decoration: BoxDecoration( color: _rate == r ? colors.primaryContainer : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Text('${r}x', style: TextStyle( fontSize: 14, fontWeight: _rate == r ? FontWeight.w600 : FontWeight.w400, color: _rate == r ? colors.onPrimaryContainer : colors.onSurface)), ), )) .toList(), ), ]), ), ), ); if (selected != null && _player != null) { await _player!.setRate(selected); // setRate 会通过 stream 回写 _rate,但同步设置避免延迟 if (mounted) setState(() => _rate = selected); } } void _toggleControls() { setState(() => _showControls = !_showControls); if (_showControls) { _restartHideControlsTimer(); } else { _hideControlsTimer?.cancel(); } } void _restartHideControlsTimer() { _hideControlsTimer?.cancel(); _hideControlsTimer = Timer(const Duration(seconds: 4), () { if (mounted && _isPlayerPlaying && !_isSeeking) { setState(() => _showControls = false); } }); } String _fmtDuration(Duration d) { final h = d.inHours; final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return h > 0 ? '$h:$m:$s' : '$m:$s'; } void _closePlayer() { _player?.pause(); setState(() { _isPlaying = false; }); } Future _enterFullscreen() async { await SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); setState(() { _isFullscreen = true; }); } Future _exitFullscreen() async { await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); setState(() { _isFullscreen = false; }); } void _checkLocal() { final name = _data?['vod_name'] ?? ''; if (name.toString().isEmpty) return; final provider = context.read(); final match = provider.movies.where((m) => !m.isDeleted && m.title == name).toList(); if (match.isNotEmpty) { setState(() { _localMovie = match.first; }); } } Future _addMovie(String status) async { final m = _data!; final name = m['vod_name'] ?? ''; final director = m['vod_director'] ?? ''; final actorStr = m['vod_actor'] ?? ''; final classStr = m['vod_class'] ?? ''; final yearStr = m['vod_year'] ?? ''; final scoreStr = m['vod_score'] ?? ''; final content = m['vod_content'] ?? m['vod_blurb'] ?? ''; final pic = m['vod_pic'] ?? ''; DateTime? releaseDate; final pubdateStr = m['vod_pubdate'] ?? ''; final dateSource = pubdateStr.toString().isNotEmpty ? pubdateStr : yearStr; if (dateSource.toString().isNotEmpty) { // 提取 yyyy-MM-dd 部分,兼容 "2025-09-08(多伦多电影节)" 等格式 final match = RegExp(r'(\d{4}-\d{2}-\d{2})').firstMatch(dateSource.toString()); if (match != null) { releaseDate = DateTime.tryParse(match.group(1)!); } else { // 纯年份如 "2025" final yearMatch = RegExp(r'^(\d{4})$').firstMatch(dateSource.toString()); if (yearMatch != null) { releaseDate = DateTime.tryParse('${yearMatch.group(1)}-01-01'); } } } final movieId = const Uuid().v4(); String? posterPath; if (pic.toString().isNotEmpty) { try { final resp = await http.get(Uri.parse(pic.toString()), headers: { 'User-Agent': 'Mozilla/5.0' }).timeout(const Duration(seconds: 15)); if (resp.statusCode == 200 && resp.bodyBytes.length < 10 * 1024 * 1024) { final fileName = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg'; final targetPath = await ImagePathHelper.instance .getMoviePosterPath(movieId, fileName); await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); await File(targetPath).writeAsBytes(resp.bodyBytes); posterPath = targetPath; } } catch (_) {} } final movie = Movie( id: movieId, title: name.toString(), posterPath: posterPath, releaseDate: releaseDate, directors: _splitStr(director.toString()), actors: _splitStr(actorStr.toString()), genres: _splitStr(classStr.toString()), summary: content.toString(), rating: double.tryParse(scoreStr.toString()), status: status, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); if (!mounted) return; final provider = context.read(); await provider.addMovie(movie); await provider.loadMovies(); if (mounted) { setState(() { _localMovie = provider.movies.firstWhere((m) => m.id == movie.id); }); ToastUtil.show(context, '已添加到${_statusLabel(status)}'); } } List _splitStr(String s) => s .split(RegExp(r'[,,/、]')) .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); String _statusLabel(String status) { switch (status) { case 'watched': return '已看'; case 'watching': return '在看'; case 'want_to_watch': return '想看'; default: return ''; } } void _showAddSheet() { final colors = Theme.of(context).colorScheme; appModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), child: Column(mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 14), decoration: BoxDecoration( color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))), Text('添加到', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), const SizedBox(height: 14), _sheetItem( ctx, colors, Icons.check_circle_outline, '已看', 'watched'), _sheetItem( ctx, colors, Icons.play_circle_outline, '在看', 'watching'), _sheetItem( ctx, colors, Icons.bookmark_outline, '想看', 'want_to_watch'), ]), ), ), ); } Widget _sheetItem(BuildContext ctx, ColorScheme colors, IconData icon, String label, String status) { return InkWell( onTap: () { Navigator.pop(ctx); _addMovie(status); }, borderRadius: BorderRadius.circular(10), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), margin: const EdgeInsets.only(bottom: 4), child: Row(children: [ Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)), const SizedBox(width: 12), Text(label, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), ]), ), ); } // ── 播放器区域 ────────────────────────────────────────── Widget _buildPlayerArea(ColorScheme colors) { return Column(children: [ SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row(children: [ GestureDetector( onTap: () => Navigator.pop(context), child: Container( width: 36, height: 36, decoration: BoxDecoration( color: colors.surfaceContainerHigh, shape: BoxShape.circle), child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface), ), ), const Spacer(), GestureDetector( onTap: _closePlayer, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(18)), child: Text('关闭播放器', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface)), ), ), ]), ), ), AspectRatio( aspectRatio: 16 / 9, child: _buildVideoStack(colors, fullscreen: false), ), ]); } Widget _buildFullscreenPlayer(ColorScheme colors) { return Scaffold( backgroundColor: Colors.black, body: _buildVideoStack(colors, fullscreen: true), ); } /// 自定义控件的视频区域:底层 Video + 叠加控件层 Widget _buildVideoStack(ColorScheme colors, {required bool fullscreen}) { final hasController = _videoController != null; final episodes = _playSources.isNotEmpty ? _playEpisodes[_currentSource] : <({String name, String url})>[]; final epName = (_currentEpisode != null && _currentEpisode! < episodes.length) ? episodes[_currentEpisode!].name : ''; final hasPrev = _currentEpisode != null && _currentEpisode! > 0; final hasNext = _currentEpisode != null && _currentEpisode! < episodes.length - 1; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _toggleControls, onLongPressStart: fullscreen ? (_) => _startLongPress2x() : null, onLongPressEnd: fullscreen ? (_) => _stopLongPress2x() : null, child: Stack( fit: StackFit.expand, children: [ // 视频画面 if (hasController) Video( controller: _videoController!, controls: null, fit: BoxFit.contain, onEnterFullscreen: _enterFullscreen, onExitFullscreen: _exitFullscreen, ) else Container(color: Colors.black), // 缓冲指示 if (_isBuffering && hasController) const Center( child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2.5, ), ), // 中央播放/暂停按钮(暂停且非缓冲时) if (!_isPlayerPlaying && !_isBuffering && hasController) Center( child: GestureDetector( onTap: () => _player?.play(), child: Container( width: 56, height: 56, decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle, ), child: const Icon(Icons.play_arrow, color: Colors.white, size: 36), ), ), ), // 长按 2x 提示 if (_isLongPress2x) Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(8), ), child: const Text('2x', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600)), ), ), // 控件层 if (_showControls) ...[ // 顶部渐变 + 标题 Positioned( top: 0, left: 0, right: 0, child: Container( padding: EdgeInsets.fromLTRB( fullscreen ? 24 : 8, fullscreen ? 24 : 8, 8, 8), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.5), Colors.transparent, ], ), ), child: Row(children: [ if (fullscreen) GestureDetector( onTap: _exitFullscreen, child: const Padding( padding: EdgeInsets.all(4), child: Icon(Icons.arrow_back, color: Colors.white, size: 22), ), ), if (epName.isNotEmpty) ...[ const SizedBox(width: 8), Expanded( child: Text(epName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)), ), ], const Spacer(), if (!fullscreen) GestureDetector( onTap: _enterFullscreen, child: const Padding( padding: EdgeInsets.all(4), child: Icon(Icons.fullscreen, color: Colors.white, size: 22), ), ), ]), ), ), // 底部渐变 + 进度条 + 按钮 Positioned( bottom: 0, left: 0, right: 0, child: Container( padding: EdgeInsets.fromLTRB( 8, 8, 8, fullscreen ? 16 : 8), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withValues(alpha: 0.5), Colors.transparent, ], ), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ // 进度条 Row(children: [ Text(_fmtDuration(_position), style: const TextStyle( color: Colors.white, fontSize: 11, fontFeatures: [FontFeature.tabularFigures()])), Expanded( child: SliderTheme( data: SliderThemeData( trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), trackShape: const RectangularSliderTrackShape(), activeTrackColor: Colors.white, inactiveTrackColor: Colors.white.withValues(alpha: 0.3), thumbColor: Colors.white, ), child: Slider( value: _position.inMilliseconds.toDouble().clamp( 0, _duration.inMilliseconds.toDouble() > 0 ? _duration.inMilliseconds.toDouble() : 1), min: 0, max: _duration.inMilliseconds.toDouble() > 0 ? _duration.inMilliseconds.toDouble() : 1, onChangeStart: (_) { setState(() => _isSeeking = true); _hideControlsTimer?.cancel(); }, onChanged: (v) { setState(() { _position = Duration(milliseconds: v.round()); }); }, onChangeEnd: (v) async { await _player ?.seek(Duration(milliseconds: v.round())); setState(() => _isSeeking = false); _restartHideControlsTimer(); }, ), ), ), Text(_fmtDuration(_duration), style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 11, fontFeatures: const [ FontFeature.tabularFigures() ])), ]), // 按钮行 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // 上一集 IconButton( onPressed: hasPrev ? _playPrevEpisode : null, icon: Icon(Icons.skip_previous, color: hasPrev ? Colors.white : Colors.white.withValues(alpha: 0.3)), iconSize: 22, padding: const EdgeInsets.symmetric(horizontal: 8), constraints: const BoxConstraints( minWidth: 36, minHeight: 36), ), // 播放/暂停 GestureDetector( onTap: () { if (_isPlayerPlaying) { _player?.pause(); } else { _player?.play(); } _restartHideControlsTimer(); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Icon( _isPlayerPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, color: Colors.white, size: 32), ), ), // 下一集 IconButton( onPressed: hasNext ? _playNextEpisode : null, icon: Icon(Icons.skip_next, color: hasNext ? Colors.white : Colors.white.withValues(alpha: 0.3)), iconSize: 22, padding: const EdgeInsets.symmetric(horizontal: 8), constraints: const BoxConstraints( minWidth: 36, minHeight: 36), ), const SizedBox(width: 16), // 倍速 GestureDetector( onTap: _showRateSheet, child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), ), child: Text( _rate == 1.0 ? '倍速' : '${_rate}x', style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500), ), ), ), ], ), ]), ), ), ], ], ), ); } void _startLongPress2x() { if (!_isPlayerPlaying) return; _player?.setRate(2.0); setState(() => _isLongPress2x = true); _hideControlsTimer?.cancel(); } void _stopLongPress2x() { _player?.setRate(_rate); setState(() => _isLongPress2x = false); _restartHideControlsTimer(); } // ── Build ────────────────────────────────────────────── @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; if (_isFullscreen && _isPlaying) return _buildFullscreenPlayer(colors); return Scaffold( backgroundColor: colors.surface, floatingActionButton: (!_loading && _error == null && _localMovie == null && _data != null && _currentTab != 2) ? FloatingActionButton( onPressed: _showAddSheet, backgroundColor: colors.primary, child: Icon(Icons.add, color: colors.onPrimary)) : null, body: _loading ? Center( child: CircularProgressIndicator( color: colors.primary, strokeWidth: 2)) : _error != null ? _buildError(colors) : _buildBody(colors), ); } Widget _buildError(ColorScheme colors) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 48, color: colors.onSurface.withValues(alpha: 0.2)), const SizedBox(height: 16), Text(_error!, style: TextStyle( fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), const SizedBox(height: 16), TextButton( onPressed: () { setState(() { _loading = true; _error = null; }); _load(); }, child: Text('重试', style: TextStyle(color: colors.primary))), ], )); } Widget _buildBody(ColorScheme colors) { if (_detailStyle == 1) return _buildImmersiveBody(colors); final m = _data!; final pic = m['vod_pic'] ?? ''; final name = m['vod_name'] ?? ''; final isEnd = m['vod_isend'] ?? 0; final year = m['vod_year'] ?? ''; final area = m['vod_area'] ?? ''; final typeName = m['type_name'] ?? ''; final classStr = m['vod_class'] ?? ''; final score = m['vod_score'] ?? ''; final metaParts = [year, area].where((s) => s.toString().isNotEmpty).join(' · '); final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / '); return Scaffold( backgroundColor: colors.surface, body: Column(children: [ // 头部:返回按钮 + 海报信息(播放时替换为播放器区域) _isPlaying ? _buildPlayerArea(colors) : SafeArea( bottom: false, child: Column(children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row(children: [ GestureDetector( onTap: () => Navigator.pop(context), child: Container( width: 36, height: 36, decoration: BoxDecoration( color: colors.surfaceContainerHigh, shape: BoxShape.circle), child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface)), ), const Spacer(), GestureDetector( onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0), child: Container( width: 36, height: 36, decoration: BoxDecoration( color: colors.surfaceContainerHigh, shape: BoxShape.circle), child: Icon( _detailStyle == 0 ? Icons.crop_landscape_rounded : Icons.grid_view_rounded, size: 18, color: colors.onSurface)), ), ]), ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(10), child: SizedBox( width: 120, height: 170, child: pic.toString().isNotEmpty ? Image.network(pic, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _posterPlaceholder(colors)) : _posterPlaceholder(colors), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)), const SizedBox(height: 8), if (score.toString().isNotEmpty && score != '0.0') ...[ Row(children: [ Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)), const SizedBox(width: 3), Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), ]), const SizedBox(height: 2), Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))), const SizedBox(height: 8), ], _endTag(isEnd), if (metaParts.isNotEmpty) ...[ const SizedBox(height: 8), Text(metaParts, style: TextStyle( fontSize: 12, color: colors.onSurface .withValues(alpha: 0.5))), ], if (typeParts.isNotEmpty) ...[ const SizedBox(height: 3), Text(typeParts, style: TextStyle( fontSize: 11, color: colors.onSurface .withValues(alpha: 0.4)), maxLines: 1, overflow: TextOverflow.ellipsis), ], if (_localMovie != null) ...[ const SizedBox(height: 10), _buildLocalStatus(colors), ], ]), ), ]), ), ]), ), // Tab 栏 Container( decoration: BoxDecoration( color: colors.surface, border: Border( bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), child: Row(children: [ _buildTabButton('概要', 0), _buildTabButton('演职人员', 1), if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2), ]), ), // Tab 内容 Expanded( child: _currentTab == 0 ? _buildOverview(colors) : _currentTab == 1 ? _buildStaffTab(colors) : _buildPlayTab(colors), ), ]), ); } // ── 沉浸式布局 ────────────────────────────────────────── Widget _buildImmersiveBody(ColorScheme colors) { final m = _data!; final pic = m['vod_pic'] ?? ''; final name = m['vod_name'] ?? ''; final isEnd = m['vod_isend'] ?? 0; final year = m['vod_year'] ?? ''; final area = m['vod_area'] ?? ''; final typeName = m['type_name'] ?? ''; final classStr = m['vod_class'] ?? ''; final score = m['vod_score'] ?? ''; final metaParts = [year, area].where((s) => s.toString().isNotEmpty).join(' · '); final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / '); return Scaffold( backgroundColor: colors.surface, body: Column(children: [ // 沉浸式头部(播放时替换为播放器区域) _isPlaying ? _buildPlayerArea(colors) : Stack(children: [ SizedBox( width: double.infinity, height: 320, child: pic.toString().isNotEmpty ? Image.network(pic, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest)) : Container(color: colors.surfaceContainerHighest, child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))), ), Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)], stops: const [0.35, 1.0], ), ), ), ), SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row(children: [ GestureDetector( onTap: () => Navigator.pop(context), child: Container( width: 36, height: 36, decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)), ), const Spacer(), GestureDetector( onTap: () => setState(() => _detailStyle = 0), child: Container( width: 36, height: 36, decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)), ), ]), ), ), Positioned( left: 16, right: 16, bottom: 18, child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)), const SizedBox(height: 8), Row(children: [ if (score.toString().isNotEmpty && score != '0.0') ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400), const SizedBox(width: 3), Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)), ], ), ), const SizedBox(width: 10), ], _endTag(isEnd), if (metaParts.isNotEmpty) ...[ const SizedBox(width: 8), Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))), ], ]), if (typeParts.isNotEmpty) ...[ const SizedBox(height: 4), Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))), ], if (_localMovie != null) ...[ const SizedBox(height: 8), _buildLocalStatus(colors), ], ]), ), ]), // Tab 栏 Container( decoration: BoxDecoration( color: colors.surface, border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), child: Row(children: [ _buildTabButton('概要', 0), _buildTabButton('演职人员', 1), if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2), ]), ), // Tab 内容 Expanded( child: _currentTab == 0 ? _buildOverview(colors) : _currentTab == 1 ? _buildStaffTab(colors) : _buildPlayTab(colors), ), ]), ); } Widget _posterPlaceholder(ColorScheme colors) { return Container( color: colors.surfaceContainerHighest, child: Center( child: Icon(Icons.movie_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.15)))); } Widget _endTag(int isEnd) { final finished = isEnd == 1; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: finished ? const Color(0xFF16A34A).withValues(alpha: 0.1) : const Color(0xFFF59E0B).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), ), child: Text(finished ? '已完结' : '连载中', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: finished ? const Color(0xFF16A34A) : const Color(0xFFF59E0B))), ); } Widget _buildLocalStatus(ColorScheme colors) { final status = _localMovie!.status; final label = _statusLabel(status); Color dotColor; switch (status) { case 'watched': dotColor = colors.primary; break; case 'watching': dotColor = const Color(0xFF666666); break; default: dotColor = const Color(0xFF999999); break; } return Row(children: [ Container( width: 6, height: 6, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)), const SizedBox(width: 6), Text('已在本地 · $label', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), ]); } Widget _buildTabButton(String label, int index) { final colors = Theme.of(context).colorScheme; final selected = _currentTab == index; return GestureDetector( onTap: () { setState(() => _currentTab = index); if (index == 2 && UserPrefs().playbackUnlocked && _playSources.isEmpty && !_playLoading) { _loadPlayInfo(); } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: selected ? colors.primary : Colors.transparent, width: 2))), child: Text(label, style: TextStyle( fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.4))), ), ); } // ── 概要 Tab ────────────────────────────────────────── Widget _buildOverview(ColorScheme colors) { final m = _data!; final isManual = m['is_manual_optimized'] ?? 0; return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), children: [ // 信息行 _infoRow(colors, '导演', m['vod_director']), _infoRow(colors, '主演', _formatActors()), _infoRow(colors, '语言', m['vod_lang']), _infoRow(colors, '时长', m['vod_duration']), _infoRow(colors, '上映', m['vod_pubdate']), const SizedBox(height: 16), // 标签 if (isManual == 1 || (m['vod_tag'] ?? '').toString().isNotEmpty) ...[ _buildTags(colors), const SizedBox(height: 16), ], // 分隔线 Container(height: 0.5, color: colors.outlineVariant), const SizedBox(height: 16), // 简介 _buildSynopsis(colors), ], ); } Widget _infoRow(ColorScheme colors, String label, dynamic value) { final text = value?.toString() ?? ''; if (text.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 44, child: Text(label, style: TextStyle( fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)))), const SizedBox(width: 8), Expanded( child: Text(text, style: TextStyle( fontSize: 12, color: colors.onSurface.withValues(alpha: 0.75), height: 1.5))), ]), ); } String _formatActors() { final staffStr = _data?['vod_staff'] ?? ''; if (staffStr.toString().isNotEmpty) { try { final staff = json.decode(staffStr.toString()) as List; final actors = staff.where((s) => s['position'] == '演员').toList(); if (actors.isNotEmpty) { return actors.map((s) { final name = s['name'] ?? ''; final role = s['role'] ?? ''; if (role.toString().isNotEmpty) return '$name($role)'; return name; }).join(','); } } catch (_) {} } return _data?['vod_actor'] ?? ''; } Widget _buildTags(ColorScheme colors) { final m = _data!; final isManual = m['is_manual_optimized'] ?? 0; final tagStr = m['vod_tag'] ?? ''; final tags = tagStr.toString().split(',').where((t) => t.trim().isNotEmpty).toList(); return Wrap( spacing: 6, runSpacing: 6, children: [ if (isManual == 1) _tag('官方优化', const Color(0xFF16A34A), highlight: true), ...tags.map( (t) => _tag(t.trim(), colors.onSurface.withValues(alpha: 0.5))), ], ); } Widget _tag(String text, Color color, {bool highlight = false}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: highlight ? const Color(0xFF16A34A).withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(6), border: Border.all( color: highlight ? const Color(0xFF16A34A).withValues(alpha: 0.3) : color.withValues(alpha: 0.2), width: 0.5), ), child: Text(text, style: TextStyle( fontSize: 11, color: highlight ? const Color(0xFF16A34A) : color)), ); } Widget _buildSynopsis(ColorScheme colors) { final m = _data!; final blurb = m['vod_blurb'] ?? ''; final content = m['vod_content'] ?? ''; final fullText = content.toString().isNotEmpty ? content.toString().replaceAll(RegExp(r'<[^>]*>'), '') : blurb.toString(); final isLong = fullText.length > 120; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ AnimatedCrossFade( duration: const Duration(milliseconds: 200), crossFadeState: _expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, firstChild: Text(fullText, style: TextStyle( fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7), height: 1.8), maxLines: 4, overflow: TextOverflow.ellipsis), secondChild: Text(fullText, style: TextStyle( fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7), height: 1.8)), ), if (isLong) GestureDetector( onTap: () => setState(() => _expanded = !_expanded), child: Padding( padding: const EdgeInsets.only(top: 8), child: Text(_expanded ? '收起' : '展开全文', style: TextStyle( fontSize: 12, color: colors.primary, fontWeight: FontWeight.w500)), ), ), ]); } // ── 播放 Tab ────────────────────────────────────────── Widget _buildPlayTab(ColorScheme colors) { if (!UserPrefs().playbackUnlocked) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.lock_outline, size: 40, color: colors.onSurface.withValues(alpha: 0.2)), const SizedBox(height: 12), Text('播放功能未解锁', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))), const SizedBox(height: 6), Text('请在增强搜索中输入验证码以解锁(7天有效)', style: TextStyle( fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), ], ), ), ); } if (_playLoading) { return Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: colors.primary, ), ), ); } if (_playError != null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 40, color: colors.onSurface.withValues(alpha: 0.2)), const SizedBox(height: 12), Text(_playError!, style: TextStyle( fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), const SizedBox(height: 12), TextButton( onPressed: _loadPlayInfo, child: Text('重试', style: TextStyle(color: colors.primary)), ), ], ), ); } if (_playSources.isEmpty || _playEpisodes.isEmpty) { return Center( child: Text('暂无播放资源', style: TextStyle( fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))), ); } final episodes = _playEpisodes[_currentSource]; return Column(children: [ // 播放源选择器 SizedBox( height: 44, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), itemCount: _playSources.length, itemBuilder: (_, i) { final selected = _currentSource == i; return GestureDetector( onTap: () { setState(() { _currentSource = i; _currentEpisode = null; }); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( color: selected ? colors.primaryContainer : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text(_playSources[i], style: TextStyle( fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? colors.onPrimaryContainer : colors.onSurface.withValues(alpha: 0.6), )), ), ); }, ), ), const SizedBox(height: 8), // 剧集网格 Expanded( child: GridView.builder( padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 5, crossAxisSpacing: 6, mainAxisSpacing: 6, childAspectRatio: 1.4, ), itemCount: episodes.length, itemBuilder: (_, i) { final ep = episodes[i]; final isCurrent = _currentEpisode == i && _isPlaying; final isPlayed = _playedEpisodes.contains(ep.name); return GestureDetector( onTap: () => _startPlay(ep.url, i), child: Container( decoration: BoxDecoration( color: isCurrent ? colors.primary : isPlayed ? colors.surfaceContainerHigh : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text(ep.name, style: TextStyle( fontSize: 12, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, color: isCurrent ? colors.onPrimary : isPlayed ? colors.onSurface.withValues(alpha: 0.5) : colors.onSurface, ), maxLines: 1, overflow: TextOverflow.ellipsis), ), ); }, ), ), ]); } // ── 演职人员 Tab ────────────────────────────────────────── Widget _buildStaffTab(ColorScheme colors) { if (_staffLoading) return Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: colors.primary))); if (_staffList.isEmpty) return Center( child: Text('暂无演职信息', style: TextStyle( fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35)))); return GridView.builder( padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 10, mainAxisSpacing: 14, childAspectRatio: 0.7), itemCount: _staffList.length, itemBuilder: (context, index) => _buildStaffCard(colors, _staffList[index]), ); } Widget _buildStaffCard(ColorScheme colors, Map s) { final name = s['name'] ?? ''; final position = s['position'] ?? ''; final role = s['role'] ?? ''; final pic = s['actor_pic'] ?? ''; final sub = [position, if (role.toString().isNotEmpty) role].join(' · '); return Column(children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( width: double.infinity, height: 100, child: pic.toString().isNotEmpty ? Image.network(pic, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _avatarPlaceholder(colors, name)) : _avatarPlaceholder(colors, name), ), ), const SizedBox(height: 6), Text(name, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center), const SizedBox(height: 2), Text(sub, style: TextStyle( fontSize: 9, color: colors.onSurface.withValues(alpha: 0.4)), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center), ]); } Widget _avatarPlaceholder(ColorScheme colors, String name) { final ch = name.isNotEmpty ? name.characters.first : '?'; return Container( color: colors.surfaceContainerHighest, child: Center( child: Text(ch, style: TextStyle( fontSize: 22, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.25)))), ); } }