diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 66c841a..11f1d3a 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -35,12 +35,11 @@ import '../../core/theme/app_colors.dart'; import '../../core/navigation/fade_page_route.dart'; import '../../l10n/app_localizations.dart'; import '../discover/search_page.dart'; -import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; -import 'widgets/episode_picker_sheet.dart'; +import 'widgets/drama_info_sheet.dart'; typedef _PlaybackSnapshot = ({ Duration position, @@ -1294,10 +1293,7 @@ class PlayerLogic extends ChangeNotifier { } // 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。 // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 - await Future.wait([ - pendingWrite, - if (!resumeLoaded) loadResumePoint(), - ]); + await Future.wait([pendingWrite, if (!resumeLoaded) loadResumePoint()]); if (resumePositionFor(episode.id) == null) { final dramaId = _dramaIdForEpisode(episode.id); final cached = dramaId == null @@ -1836,22 +1832,9 @@ class PlayerLogic extends ChangeNotifier { if (context.mounted) Navigator.of(context).pop(); } - Future openDetail(BuildContext context) async { - session.setOverlayOpen(true); - await saveCurrentProgress(); - try { - if (!context.mounted) return; - await Navigator.of(context).push( - FadePageRoute( - builder: (_) => - DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama), - ), - ); - } finally { - session.setOverlayOpen(false); - } - await saveCurrentProgress(); - } + /// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。 + Future openDetail(BuildContext context) => + _showDramaSheet(context, DramaInfoTab.detail); String? _dramaIdForEpisode(String episodeId) { for (final entry in queue.entries) { @@ -1887,13 +1870,21 @@ class PlayerLogic extends ChangeNotifier { await resumeAfterFullPlayer(); } - Future showEpisodes(BuildContext context) async { + /// 选集入口:同一个弹窗,直接停在选集页签。 + Future showEpisodes(BuildContext context) => + _showDramaSheet(context, DramaInfoTab.episodes); + + Future _showDramaSheet( + BuildContext context, + DramaInfoTab initialTab, + ) async { final entry = queue.entryAt(session.currentIndex); final drama = await _withFullEpisodes(context, entry.drama); if (!context.mounted) return; final homeFeed = feedDramas != null; final currentNumber = entry.episode.number; session.setOverlayOpen(true); + await saveCurrentProgress(); await session.playbackSettled; if (!context.mounted) { session.setOverlayOpen(false); @@ -1904,16 +1895,20 @@ class PlayerLogic extends ChangeNotifier { context: context, backgroundColor: AppColors.transparent, isScrollControlled: true, - builder: (_) => EpisodePickerSheet( + builder: (_) => DramaInfoSheet( drama: drama, currentNumber: currentNumber, - onSelect: (index) => + initialTab: initialTab, + onSelectEpisode: (index) => _selectFromSheet(context, drama, index, homeFeed, currentNumber), + onOpenRelated: (related) => + unawaited(openSeriesAt(context, related, 0)), ), ); } finally { session.setOverlayOpen(false); } + await saveCurrentProgress(); } /// 列表卡片只带一集可播预览,需要完整剧集时补一次详情; diff --git a/lib/pages/player/widgets/drama_info_sheet.dart b/lib/pages/player/widgets/drama_info_sheet.dart new file mode 100644 index 0000000..3812408 --- /dev/null +++ b/lib/pages/player/widgets/drama_info_sheet.dart @@ -0,0 +1,571 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_glow_panel.dart'; +import '../../../core/widgets/auth_gate.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../core/data/services/catalog_controller.dart'; +import '../../../core/data/services/favorite_controller.dart'; +import 'episode_picker_cell.dart'; + +const int _columns = 6; +const double _sheetHeightFactor = 0.62; +const double _cellAspect = 1.05; +const double _sheetRadius = 20; +const double _pageHorizontal = 20; +const double _tabGap = 20; +const double _posterWidth = 86; +const double _posterRadius = 8; +const double _relatedWidth = 104; +const double _relatedGap = 10; +const int _synopsisLines = 3; +const int _maxTags = 4; +const int _maxRelated = 8; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +const TextStyle _synopsisStyle = TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, +); + +/// 弹窗页签:简介 / 选集。 +enum DramaInfoTab { detail, episodes } + +/// 播放页的剧名弹窗:简介与选集在同一个面板内切换,不再跳转详情页。 +/// +/// 布局:拖拽条 → 页签 + 关闭 → 内容(海报信息块 / 选集网格)。 +class DramaInfoSheet extends StatefulWidget { + const DramaInfoSheet({ + super.key, + required this.drama, + required this.currentNumber, + required this.onSelectEpisode, + this.onOpenRelated, + this.initialTab = DramaInfoTab.detail, + }); + + final Drama drama; + + /// 当前播放集号,用于选集网格高亮。 + final int currentNumber; + + /// 点击某集,参数为该集在原始列表中的索引。 + final ValueChanged onSelectEpisode; + + /// 点相似推荐里的短剧,交给外层决定怎么打开。 + final ValueChanged? onOpenRelated; + + final DramaInfoTab initialTab; + + @override + State createState() => _DramaInfoSheetState(); +} + +class _DramaInfoSheetState extends State { + late DramaInfoTab _tab = widget.initialTab; + bool _synopsisExpanded = false; + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + key: const Key('drama-info-system-overlay'), + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: AppColors.transparent, + systemNavigationBarColor: AppColors.card, + systemNavigationBarDividerColor: AppColors.transparent, + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarContrastEnforced: false, + ), + child: AppGlowPanel( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), + border: false, + child: Container( + key: const Key('drama-info-sheet'), + height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, + decoration: const BoxDecoration(color: AppColors.transparent), + child: SafeArea( + top: false, + bottom: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _grabber(), + _headerRow(AppLocalizations.of(context)!), + Expanded( + child: _tab == DramaInfoTab.detail + ? _detailPane() + : _episodePane(), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _grabber() { + return Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 8), + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } + + Widget _headerRow(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(_pageHorizontal, 6, 8, 2), + child: Row( + children: [ + _tabButton( + key: const Key('drama-info-tab-detail'), + label: l10n.synopsisTitle, + tab: DramaInfoTab.detail, + ), + const SizedBox(width: _tabGap), + _tabButton( + key: const Key('drama-info-tab-episodes'), + label: l10n.episodes, + tab: DramaInfoTab.episodes, + ), + const Spacer(), + _closeButton(l10n), + ], + ), + ); + } + + /// 视觉上是个小圆钮,点按区域仍是 40pt,避免太小点不中。 + Widget _closeButton(AppLocalizations l10n) { + return SizedBox( + key: const Key('drama-info-close'), + width: 40, + height: 40, + child: Center( + child: Semantics( + button: true, + label: l10n.close, + child: Material( + color: AppColors.raised, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => Navigator.pop(context), + child: const SizedBox( + width: 24, + height: 24, + child: Icon( + Icons.close_rounded, + size: 14, + color: AppColors.onPrimary, + ), + ), + ), + ), + ), + ), + ); + } + + Widget _tabButton({ + required Key key, + required String label, + required DramaInfoTab tab, + }) { + final selected = _tab == tab; + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: () { + if (_tab != tab) setState(() => _tab = tab); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.onSurface : AppColors.muted, + fontSize: 18, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + ), + ), + ), + ); + } + + Widget _detailPane() { + final l10n = AppLocalizations.of(context)!; + final drama = widget.drama; + final tags = _tagValues( + drama, + Localizations.localeOf(context).languageCode, + ); + final related = _relatedDramas(); + return ListView( + key: const Key('drama-info-detail'), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 12, + _pageHorizontal, + 28, + ), + children: [ + _headline(l10n, drama), + const SizedBox(height: 16), + _synopsis(l10n, drama), + if (tags.isNotEmpty) ...[const SizedBox(height: 14), _tagRow(tags)], + const SizedBox(height: 20), + _favoriteButton(l10n, drama), + if (related.isNotEmpty) ...[ + const SizedBox(height: 26), + _relatedSection(l10n, related), + ], + ], + ); + } + + /// 海报 + 剧名 + 热度。 + Widget _headline(AppLocalizations l10n, Drama drama) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_posterRadius), + child: SizedBox( + width: _posterWidth, + child: AspectRatio( + aspectRatio: 3 / 4, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + key: const Key('drama-info-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + height: 1.25, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.local_fire_department_rounded, + size: 16, + color: AppColors.blue, + ), + const SizedBox(width: 4), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + Text( + l10n.episodeCountLabel(drama.episodeCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _synopsis(AppLocalizations l10n, Drama drama) { + final synopsis = localizedDramaSynopsis(l10n, drama).trim(); + if (synopsis.isEmpty) return const SizedBox.shrink(); + return LayoutBuilder( + builder: (context, constraints) { + final painter = TextPainter( + text: TextSpan(text: synopsis, style: _synopsisStyle), + maxLines: _synopsisLines, + textDirection: TextDirection.ltr, + )..layout(maxWidth: constraints.maxWidth); + final overflows = painter.didExceedMaxLines; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + synopsis, + key: const Key('drama-info-synopsis'), + maxLines: _synopsisExpanded ? null : _synopsisLines, + overflow: _synopsisExpanded + ? TextOverflow.visible + : TextOverflow.ellipsis, + style: _synopsisStyle, + ), + if (overflows) + GestureDetector( + key: const Key('drama-info-synopsis-toggle'), + onTap: () => + setState(() => _synopsisExpanded = !_synopsisExpanded), + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _synopsisExpanded ? l10n.showLess : l10n.expandSynopsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ); + }, + ); + } + + Widget _tagRow(List tags) { + return Wrap( + key: const Key('drama-info-tags'), + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in tags) + Container( + padding: const EdgeInsets.fromLTRB(10, 5, 4, 5), + decoration: BoxDecoration( + color: AppColors.raised, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tag, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 14, + color: AppColors.subtle, + ), + ], + ), + ), + ], + ); + } + + Widget _favoriteButton(AppLocalizations l10n, Drama drama) { + final favorites = context.watch(); + final saved = favorites.isSaved(drama.id); + return SizedBox( + height: 48, + child: Material( + color: AppColors.raised, + borderRadius: BorderRadius.circular(12), + child: InkWell( + key: const Key('drama-info-favorite'), + borderRadius: BorderRadius.circular(12), + onTap: () => unawaited(_toggleFavorite(l10n, drama)), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + size: 20, + color: AppColors.onPrimary, + ), + const SizedBox(width: 8), + Text( + l10n.favorite, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _relatedSection(AppLocalizations l10n, List related) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.similarDramas, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: _relatedWidth / 3 * 4 + 8, + child: ListView.separated( + key: const Key('drama-info-related'), + scrollDirection: Axis.horizontal, + itemCount: related.length, + separatorBuilder: (_, _) => const SizedBox(width: _relatedGap), + itemBuilder: (context, index) { + final drama = related[index]; + return GestureDetector( + key: Key('drama-info-related-${drama.id}'), + onTap: () { + Navigator.pop(context); + widget.onOpenRelated?.call(drama); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: _relatedWidth, + child: AspectRatio( + aspectRatio: 3 / 4, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _episodePane() { + final l10n = AppLocalizations.of(context)!; + final episodes = widget.drama.episodes; + if (episodes.isEmpty) { + return Center( + child: Text( + l10n.noEpisodesYet, + key: const Key('drama-info-no-episodes'), + style: const TextStyle(color: AppColors.muted, fontSize: 13), + ), + ); + } + return GridView.builder( + key: const Key('drama-info-episodes'), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 8, + _pageHorizontal, + 12, + ), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _columns, + mainAxisSpacing: AppDimens.spaceSm, + crossAxisSpacing: AppDimens.spaceSm, + childAspectRatio: _cellAspect, + ), + itemCount: episodes.length, + itemBuilder: (context, index) { + final episode = episodes[index]; + return EpisodePickerCell( + key: Key('player-episode-${episode.number}'), + episode: episode, + selected: episode.number == widget.currentNumber, + lockLabel: l10n.locked, + onTap: () { + Navigator.pop(context); + widget.onSelectEpisode(index); + }, + ); + }, + ); + } + + Future _toggleFavorite(AppLocalizations l10n, Drama drama) async { + final favorites = context.read(); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !mounted) return; + if (favorites.lastErrorCode == 200007) { + await ensureSignedIn(context); + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } + + /// 相似推荐先用目录里的其它剧兜底,没有目录来源就不显示这一块。 + List _relatedDramas() { + final CatalogController catalog; + try { + catalog = context.read(); + } on ProviderNotFoundException { + return const []; + } + return catalog.dramas + .where((item) => item.id != widget.drama.id) + .take(_maxRelated) + .toList(growable: false); + } + + List _tagValues(Drama drama, String languageCode) { + final localized = drama.localizedTypeLabels(languageCode); + if (localized.isNotEmpty) { + return localized.take(_maxTags).toList(growable: false); + } + final result = []; + for (final source in [drama.genre, drama.category]) { + for (final part in source.split(_tagSplitter)) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + if (result.length == _maxTags) return result; + } + } + return result; + } +} diff --git a/lib/pages/player/widgets/episode_picker_sheet.dart b/lib/pages/player/widgets/episode_picker_sheet.dart deleted file mode 100644 index 2a4fd39..0000000 --- a/lib/pages/player/widgets/episode_picker_sheet.dart +++ /dev/null @@ -1,147 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/localized_content.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/app_glow_panel.dart'; -import '../../../l10n/app_localizations.dart'; -import 'episode_picker_cell.dart'; - -const int _columns = 6; -const double _sheetHeightFactor = 0.52; -const double _cellAspect = 1.05; -const double _sheetRadius = 20; - -/// K28 选集面板:六列等宽网格、当前集高亮、锁定集显示锁标。 -class EpisodePickerSheet extends StatelessWidget { - /// 创建选集面板。 - const EpisodePickerSheet({ - super.key, - required this.drama, - required this.currentNumber, - required this.onSelect, - }); - - /// 当前短剧。 - final Drama drama; - - /// 当前集号。 - final int currentNumber; - - /// 点击某集,参数为该集在原始列表中的索引。 - final ValueChanged onSelect; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final episodes = drama.episodes; - return AnnotatedRegion( - key: const Key('player-episodes-system-overlay'), - value: SystemUiOverlayStyle.light.copyWith( - statusBarColor: AppColors.transparent, - systemNavigationBarColor: AppColors.card, - systemNavigationBarDividerColor: AppColors.transparent, - systemNavigationBarIconBrightness: Brightness.light, - systemNavigationBarContrastEnforced: false, - ), - child: AppGlowPanel( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(_sheetRadius), - ), - border: false, - child: Container( - key: const Key('player-episodes-sheet'), - height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, - decoration: const BoxDecoration(color: AppColors.transparent), - child: SafeArea( - top: false, - bottom: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 8, 4), - child: Row( - children: [ - Expanded( - child: Text( - l10n.episodes, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), - ), - IconButton( - key: const Key('player-episodes-close'), - tooltip: l10n.close, - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close_rounded), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), - child: Row( - children: [ - Expanded( - child: Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - Text( - l10n.episodeCountLabel(episodes.length), - style: const TextStyle( - color: AppColors.muted, - fontSize: 12, - ), - ), - ], - ), - ), - Expanded( - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _columns, - mainAxisSpacing: AppDimens.spaceSm, - crossAxisSpacing: AppDimens.spaceSm, - childAspectRatio: _cellAspect, - ), - itemCount: episodes.length, - itemBuilder: (context, index) { - final episode = episodes[index]; - return EpisodePickerCell( - key: Key('player-episode-${episode.number}'), - episode: episode, - selected: episode.number == currentNumber, - lockLabel: l10n.locked, - onTap: () { - Navigator.pop(context); - onSelect(index); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart index a4e3176..d651d46 100644 --- a/test/player/player_open_detail_test.dart +++ b/test/player/player_open_detail_test.dart @@ -103,7 +103,10 @@ void main() { await _pumpSession(tester); await tester.runAsync(() => logic.session.playbackSettled); - expect(find.byType(DramaDetailPage), findsOneWidget); + expect(find.byType(DramaDetailPage), findsNothing); + expect(find.byKey(const Key('drama-info-sheet')), findsOneWidget); + expect(find.byKey(const Key('drama-info-tab-detail')), findsOneWidget); + expect(find.byKey(const Key('drama-info-detail')), findsOneWidget); expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); @@ -118,7 +121,7 @@ void main() { const Duration(minutes: 1, seconds: 22), ); - await tester.pageBack(); + await tester.tap(find.byKey(const Key('drama-info-close'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); await _pumpSession(tester);