feat(player): 剧名与选集合并为同一个弹窗

点剧名默认停在详情页签,选集入口直接停在选集页签,不再跳详情页;
详情页签补上简介、标签、收藏与相似推荐,原选集弹窗合并进来后删除。
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-29 15:44:10 +08:00
1 parent ad7aba3daf
commit 79463e65c2
4 files changed
+596 -174

No files matched your search

+20 -25
View File
@@ -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<void> openDetail(BuildContext context) async {
session.setOverlayOpen(true);
await saveCurrentProgress();
try {
if (!context.mounted) return;
await Navigator.of(context).push(
FadePageRoute<void>(
builder: (_) =>
DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama),
),
);
} finally {
session.setOverlayOpen(false);
}
await saveCurrentProgress();
}
/// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。
Future<void> 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<void> showEpisodes(BuildContext context) async {
/// 选集入口:同一个弹窗,直接停在选集页签。
Future<void> showEpisodes(BuildContext context) =>
_showDramaSheet(context, DramaInfoTab.episodes);
Future<void> _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();
}
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
@@ -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<int> onSelectEpisode;
/// 点相似推荐里的短剧,交给外层决定怎么打开。
final ValueChanged<Drama>? onOpenRelated;
final DramaInfoTab initialTab;
@override
State<DramaInfoSheet> createState() => _DramaInfoSheetState();
}
class _DramaInfoSheetState extends State<DramaInfoSheet> {
late DramaInfoTab _tab = widget.initialTab;
bool _synopsisExpanded = false;
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
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<String> 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<FavoriteController>();
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<Drama> 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<void> _toggleFavorite(AppLocalizations l10n, Drama drama) async {
final favorites = context.read<FavoriteController>();
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<Drama> _relatedDramas() {
final CatalogController catalog;
try {
catalog = context.read<CatalogController>();
} on ProviderNotFoundException {
return const [];
}
return catalog.dramas
.where((item) => item.id != widget.drama.id)
.take(_maxRelated)
.toList(growable: false);
}
List<String> _tagValues(Drama drama, String languageCode) {
final localized = drama.localizedTypeLabels(languageCode);
if (localized.isNotEmpty) {
return localized.take(_maxTags).toList(growable: false);
}
final result = <String>[];
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;
}
}
@@ -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<int> onSelect;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final episodes = drama.episodes;
return AnnotatedRegion<SystemUiOverlayStyle>(
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);
},
);
},
),
),
],
),
),
),
),
);
}
}