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 '../../core/navigation/fade_page_route.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../discover/search_page.dart';
|
import '../discover/search_page.dart';
|
||||||
import '../drama_detail/drama_detail_view.dart';
|
|
||||||
import '../mine/mine_types.dart';
|
import '../mine/mine_types.dart';
|
||||||
import '../mine/mine_view.dart';
|
import '../mine/mine_view.dart';
|
||||||
import 'player_view.dart';
|
import 'player_view.dart';
|
||||||
import 'widgets/drama_comments_sheet.dart';
|
import 'widgets/drama_comments_sheet.dart';
|
||||||
import 'widgets/episode_picker_sheet.dart';
|
import 'widgets/drama_info_sheet.dart';
|
||||||
|
|
||||||
typedef _PlaybackSnapshot = ({
|
typedef _PlaybackSnapshot = ({
|
||||||
Duration position,
|
Duration position,
|
||||||
@@ -1294,10 +1293,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
// 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。
|
// 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。
|
||||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||||
await Future.wait([
|
await Future.wait([pendingWrite, if (!resumeLoaded) loadResumePoint()]);
|
||||||
pendingWrite,
|
|
||||||
if (!resumeLoaded) loadResumePoint(),
|
|
||||||
]);
|
|
||||||
if (resumePositionFor(episode.id) == null) {
|
if (resumePositionFor(episode.id) == null) {
|
||||||
final dramaId = _dramaIdForEpisode(episode.id);
|
final dramaId = _dramaIdForEpisode(episode.id);
|
||||||
final cached = dramaId == null
|
final cached = dramaId == null
|
||||||
@@ -1836,22 +1832,9 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (context.mounted) Navigator.of(context).pop();
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openDetail(BuildContext context) async {
|
/// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。
|
||||||
session.setOverlayOpen(true);
|
Future<void> openDetail(BuildContext context) =>
|
||||||
await saveCurrentProgress();
|
_showDramaSheet(context, DramaInfoTab.detail);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _dramaIdForEpisode(String episodeId) {
|
String? _dramaIdForEpisode(String episodeId) {
|
||||||
for (final entry in queue.entries) {
|
for (final entry in queue.entries) {
|
||||||
@@ -1887,13 +1870,21 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
await resumeAfterFullPlayer();
|
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 entry = queue.entryAt(session.currentIndex);
|
||||||
final drama = await _withFullEpisodes(context, entry.drama);
|
final drama = await _withFullEpisodes(context, entry.drama);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final homeFeed = feedDramas != null;
|
final homeFeed = feedDramas != null;
|
||||||
final currentNumber = entry.episode.number;
|
final currentNumber = entry.episode.number;
|
||||||
session.setOverlayOpen(true);
|
session.setOverlayOpen(true);
|
||||||
|
await saveCurrentProgress();
|
||||||
await session.playbackSettled;
|
await session.playbackSettled;
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
session.setOverlayOpen(false);
|
session.setOverlayOpen(false);
|
||||||
@@ -1904,16 +1895,20 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
context: context,
|
context: context,
|
||||||
backgroundColor: AppColors.transparent,
|
backgroundColor: AppColors.transparent,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) => EpisodePickerSheet(
|
builder: (_) => DramaInfoSheet(
|
||||||
drama: drama,
|
drama: drama,
|
||||||
currentNumber: currentNumber,
|
currentNumber: currentNumber,
|
||||||
onSelect: (index) =>
|
initialTab: initialTab,
|
||||||
|
onSelectEpisode: (index) =>
|
||||||
_selectFromSheet(context, drama, index, homeFeed, currentNumber),
|
_selectFromSheet(context, drama, index, homeFeed, currentNumber),
|
||||||
|
onOpenRelated: (related) =>
|
||||||
|
unawaited(openSeriesAt(context, related, 0)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
session.setOverlayOpen(false);
|
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 _pumpSession(tester);
|
||||||
await tester.runAsync(() => logic.session.playbackSettled);
|
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.active, same(active));
|
||||||
expect(logic.session.currentIndex, index);
|
expect(logic.session.currentIndex, index);
|
||||||
expect(logic.session.userWantsPlay, isTrue);
|
expect(logic.session.userWantsPlay, isTrue);
|
||||||
@@ -118,7 +121,7 @@ void main() {
|
|||||||
const Duration(minutes: 1, seconds: 22),
|
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();
|
||||||
await tester.pump(const Duration(milliseconds: 400));
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
await _pumpSession(tester);
|
await _pumpSession(tester);
|
||||||
|
|||||||
Reference in new issue
Block a user