feat(player): 剧名与选集合并为同一个弹窗
点剧名默认停在详情页签,选集入口直接停在选集页签,不再跳详情页; 详情页签补上简介、标签、收藏与相似推荐,原选集弹窗合并进来后删除。
This commit is contained in:
1 parent
ad7aba3daf
commit
79463e65c2
4 files changed
+596
-174
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user