import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/contracts/script_widget.dart'; import '../../providers/di_providers.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/utils/user_error_formatter.dart'; import '../../shared/widgets/app_dialog.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/app_inline_alert.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/app_snack_bar.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/hover_scrollable_row.dart'; import '../../shared/widgets/script_video_card.dart'; import '../detail/widgets/detail_hero_panel.dart'; import '../detail/widgets/detail_immersive_scaffold.dart'; import '../detail/widgets/detail_nav_bar.dart'; import '../player/player_launcher.dart'; const _moduleDetailChromeColor = Color(0xFF08090C); class ModuleDetailPage extends ConsumerStatefulWidget { final String widgetId; final ScriptVideoItem seedItem; const ModuleDetailPage({ super.key, required this.widgetId, required this.seedItem, }); @override ConsumerState createState() => _ModuleDetailPageState(); } class _ModuleDetailPageState extends ConsumerState { final ScrollController _scrollController = ScrollController(); final ValueNotifier _scrollProgress = ValueNotifier(0); late ScriptVideoItem _item; bool _loadingDetail = false; bool _loadingResource = false; String? _detailError; @override void initState() { super.initState(); _item = widget.seedItem; _scrollController.addListener(_updateScrollProgress); unawaited(_loadDetail()); } @override void dispose() { _scrollController.removeListener(_updateScrollProgress); _scrollController.dispose(); _scrollProgress.dispose(); super.dispose(); } void _updateScrollProgress() { _scrollProgress.value = (_scrollController.offset / 180).clamp(0, 1); } Future _loadDetail() async { final link = widget.seedItem.link.trim(); if (link.isEmpty) return; setState(() { _loadingDetail = true; _detailError = null; }); try { final service = await ref.read(scriptWidgetServiceProvider.future); final loadedItem = await service.loadDetail(widget.widgetId, link); if (!mounted || loadedItem == null) return; setState(() { _item = _mergeSeedWithDetail(widget.seedItem, loadedItem); }); } catch (error) { if (!mounted) return; setState(() { _detailError = formatUserError(error); }); } finally { if (mounted) { setState(() { _loadingDetail = false; }); } } } Future _playItem(ScriptVideoItem playableItem) async { if (_loadingResource) return; setState(() { _loadingResource = true; }); try { final resources = await _resolveResources(playableItem); if (!mounted) return; if (resources.isEmpty) { showAppToast(context, '模块没有返回可播放地址', tone: AppAlertTone.error); return; } final selectedResource = resources.length == 1 ? resources.first : await showAppRawDialog( context, builder: (_) => _ResourcePickerDialog(resources: resources), ); if (selectedResource == null || !mounted) return; await _launchResource(playableItem, selectedResource); } catch (error) { if (mounted) { showAppToast(context, formatUserError(error), tone: AppAlertTone.error); } } finally { if (mounted) { setState(() { _loadingResource = false; }); } } } Future> _resolveResources( ScriptVideoItem playableItem, ) async { final service = await ref.read(scriptWidgetServiceProvider.future); final itemJson = playableItem.toJson(); final resources = await service .loadResources(widget.widgetId, { ...itemJson, 'item': itemJson, 'link': playableItem.link, 'url': playableItem.videoUrl, 'videoUrl': playableItem.videoUrl, }); if (resources.isNotEmpty) return resources; final videoUrl = playableItem.videoUrl.trim(); if (videoUrl.isEmpty) return const []; return [ ScriptVideoResource( name: playableItem.title, url: videoUrl, playerType: playableItem.playerType, ), ]; } Future _launchResource( ScriptVideoItem playableItem, ScriptVideoResource resource, ) async { final playerType = resource.playerType.trim().toLowerCase(); final shouldUseInternalPlayer = playerType.isEmpty || playerType == 'app' || playerType == 'internal'; if (!shouldUseInternalPlayer) { final launched = await launchUrl( Uri.parse(resource.url), mode: LaunchMode.externalApplication, ); if (!launched && mounted) { showAppToast(context, '无法打开外部播放器', tone: AppAlertTone.error); } return; } final itemIdentity = base64Url .encode( utf8.encode( '${widget.widgetId}:${playableItem.id}:${playableItem.link}', ), ) .replaceAll('=', ''); await PlayerLauncher.launch( context: context, itemId: 'script-$itemIdentity', directStream: DirectStreamLaunchData( url: resource.url, headers: resource.customHeaders, title: playableItem.title.isEmpty ? _item.title : playableItem.title, durationSeconds: playableItem.duration, seriesName: playableItem == _item ? null : _item.title, episode: playableItem.episode, ), ); } @override Widget build(BuildContext context) { final backdropUrl = _firstNonEmpty([ _item.backdropPath, ..._item.backdropPaths, _item.detailPoster, _item.posterPath, _item.coverUrl, ]); final posterUrl = _firstNonEmpty([ _item.detailPoster, _item.posterPath, _item.coverUrl, ]); return Scaffold( backgroundColor: _moduleDetailChromeColor, body: Stack( children: [ DetailImmersiveScaffold( scrollController: _scrollController, scrollProgress: _scrollProgress, backdropUrl: backdropUrl, fallbackUrls: _item.backdropPaths, compact: MediaQuery.sizeOf(context).width < 700, horizontalPadding: MediaQuery.sizeOf(context).width < 700 ? 18 : 32, child: _buildDetailContent(posterUrl), ), DetailNavBar( scrollProgress: _scrollProgress, title: _item.title, onBack: () => context.pop(), ), ], ), ); } Widget _buildDetailContent(String? posterUrl) { final metaParts = [ if (_item.releaseDate.isNotEmpty) _item.releaseDate, if (_item.mediaType.isNotEmpty) _item.mediaType, if (_item.durationText.isNotEmpty) _item.durationText, ]; final genres = _item.genreItems .map((genre) => genre.title) .where((title) => title.trim().isNotEmpty) .toList(); if (_item.genreTitle.trim().isNotEmpty) genres.add(_item.genreTitle); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ DetailHeroPanel( title: _item.title.isEmpty ? _item.id : _item.title, posterUrl: posterUrl, rating: _item.rating, metaParts: metaParts, genres: genres, overview: _item.description, actions: _buildPlayActions(), ), if (_loadingDetail) const Padding( padding: EdgeInsets.only(top: 28), child: Center(child: AppLoadingRing(size: 28)), ), if (_detailError != null) ...[ const SizedBox(height: 24), AppErrorState( compact: true, message: _detailError!, onRetry: _loadDetail, ), ], if (_item.episodeItems.isNotEmpty || _item.childItems.isNotEmpty) ...[ const SizedBox(height: 36), _EpisodeSection( items: _item.episodeItems.isNotEmpty ? _item.episodeItems : _item.childItems, onPlay: _playItem, ), ], if (_item.relatedItems.isNotEmpty) ...[ const SizedBox(height: 36), _RelatedSection(widgetId: widget.widgetId, items: _item.relatedItems), ], if (!_loadingDetail && _item.episodeItems.isEmpty && _item.childItems.isEmpty && _item.relatedItems.isEmpty && _item.description.trim().isEmpty) ...[ const SizedBox(height: 32), const EmptyState( compact: true, icon: Icons.info_outline_rounded, title: '暂无更多详情', message: '仍可尝试解析并播放该条目。', ), ], ], ); } Widget _buildPlayActions() { return FButton( onPress: _loadingResource ? null : () => _playItem(_item), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (_loadingResource) const AppLoadingRing(size: 17) else const Icon(Icons.play_arrow_rounded, size: 20), const SizedBox(width: 7), Text(_loadingResource ? '正在解析' : '播放'), ], ), ); } } class _ResourcePickerDialog extends StatelessWidget { final List resources; const _ResourcePickerDialog({required this.resources}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('选择播放线路', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), Text( '模块返回了 ${resources.length} 条可播放线路。', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: context.appTokens.mutedForeground, ), ), const SizedBox(height: 16), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 420), child: ListView.separated( shrinkWrap: true, itemCount: resources.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final resource = resources[index]; return FItemGroup( children: [ FItem( prefix: Container( width: 30, height: 30, alignment: Alignment.center, decoration: BoxDecoration( color: Theme.of( context, ).colorScheme.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Text( '${index + 1}', style: Theme.of(context).textTheme.labelMedium ?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w700, ), ), ), title: Text( resource.name.isEmpty ? '线路 ${index + 1}' : resource.name, ), subtitle: Text( resource.description.trim().isEmpty ? resource.playerType == 'app' ? '应用内播放' : '外部播放' : resource.description, ), suffix: const Icon(Icons.chevron_right_rounded), onPress: () => Navigator.of(context).pop(resource), ), ], ); }, ), ), const SizedBox(height: 16), Align( alignment: Alignment.centerRight, child: FButton( variant: FButtonVariant.outline, onPress: () => Navigator.of(context).pop(), child: const Text('取消'), ), ), ], ), ); } } class _EpisodeSection extends StatelessWidget { final List items; final ValueChanged onPlay; const _EpisodeSection({required this.items, required this.onPlay}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '选集', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 12), Wrap( spacing: 10, runSpacing: 10, children: [ for (var index = 0; index < items.length; index++) FButton( variant: FButtonVariant.outline, onPress: () => onPlay(items[index]), child: Text( items[index].title.isNotEmpty ? items[index].title : '第 ${items[index].episode ?? index + 1} 集', ), ), ], ), ], ); } } class _RelatedSection extends StatelessWidget { final String widgetId; final List items; const _RelatedSection({required this.widgetId, required this.items}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '相关推荐', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 12), SizedBox( height: 250, child: HoverScrollableRow( builder: (context, scrollController) => ListView.separated( controller: scrollController, scrollDirection: Axis.horizontal, itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(width: 14), itemBuilder: (context, index) { final item = items[index]; return ScriptVideoCard( item: item, width: 138, textColor: Colors.white, onTap: () => context.push( '/modules/${Uri.encodeComponent(widgetId)}/detail', extra: item, ), ); }, ), ), ), ], ); } } ScriptVideoItem _mergeSeedWithDetail( ScriptVideoItem seed, ScriptVideoItem detail, ) { return detail.copyWith( id: detail.id.isEmpty ? seed.id : detail.id, title: detail.title.isEmpty ? seed.title : detail.title, coverUrl: detail.coverUrl.isEmpty ? seed.coverUrl : detail.coverUrl, posterPath: detail.posterPath.isEmpty ? seed.posterPath : detail.posterPath, detailPoster: detail.detailPoster.isEmpty ? seed.detailPoster : detail.detailPoster, backdropPath: detail.backdropPath.isEmpty ? seed.backdropPath : detail.backdropPath, link: detail.link.isEmpty ? seed.link : detail.link, videoUrl: detail.videoUrl.isEmpty ? seed.videoUrl : detail.videoUrl, ); } String? _firstNonEmpty(List values) { for (final value in values) { if (value.trim().isNotEmpty) return value; } return null; }