Compare commits
6
Commits
ad7aba3daf
...
3590cd0c55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3590cd0c55 | ||
|
|
c311985601 | ||
|
|
4dd37b2588 | ||
|
|
543d0aadd5 | ||
|
|
d5bcda54d3 | ||
|
|
79463e65c2 |
No files matched your search
@@ -102,6 +102,7 @@ class Drama {
|
||||
this.synopsisLocales = const {},
|
||||
this.tag = '',
|
||||
this.tagLocales = const {},
|
||||
this.tagCode = '',
|
||||
this.tagColor = '',
|
||||
this.category = '',
|
||||
this.categoryLocales = const {},
|
||||
@@ -127,6 +128,9 @@ class Drama {
|
||||
final Map<String, String> synopsisLocales;
|
||||
final String tag;
|
||||
final Map<String, String> tagLocales;
|
||||
|
||||
/// 标签语义码(如 `NEW_ARRIVAL`)。文案会随语言变,语义判断只认这个码。
|
||||
final String tagCode;
|
||||
final String tagColor;
|
||||
final String posterUrl;
|
||||
final List<Episode> episodes;
|
||||
@@ -172,6 +176,7 @@ class Drama {
|
||||
synopsisLocales: text.synopsisLocales,
|
||||
tag: text.tag,
|
||||
tagLocales: text.tagLocales,
|
||||
tagCode: (json['tagCode'] as String? ?? '').trim(),
|
||||
tagColor: json['tagColor'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
episodes: preview is Map<String, dynamic>
|
||||
@@ -204,6 +209,7 @@ class Drama {
|
||||
synopsisLocales: text.synopsisLocales,
|
||||
tag: text.tag,
|
||||
tagLocales: text.tagLocales,
|
||||
tagCode: (json['tagCode'] as String? ?? '').trim(),
|
||||
tagColor: json['tagColor'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
category: text.category,
|
||||
@@ -315,7 +321,10 @@ class _DramaText {
|
||||
final category = _firstText(categoryLocales, json['category'], types);
|
||||
return _DramaText(
|
||||
title: _firstText(titleLocales, json['title']),
|
||||
synopsis: _firstText(synopsisLocales, json['synopsis'] ?? json['description']),
|
||||
synopsis: _firstText(
|
||||
synopsisLocales,
|
||||
json['synopsis'] ?? json['description'],
|
||||
),
|
||||
titleLocales: titleLocales,
|
||||
synopsisLocales: synopsisLocales,
|
||||
tag: _firstText(tagLocales, json['tag']),
|
||||
@@ -328,7 +337,11 @@ class _DramaText {
|
||||
}
|
||||
}
|
||||
|
||||
String _firstText(Map<String, String> locales, Object? fallback, [List<Map<String, String>>? types]) {
|
||||
String _firstText(
|
||||
Map<String, String> locales,
|
||||
Object? fallback, [
|
||||
List<Map<String, String>>? types,
|
||||
]) {
|
||||
final chinese = locales['zh'] ?? '';
|
||||
if (chinese.isNotEmpty) return chinese;
|
||||
if (types != null && types.isNotEmpty) {
|
||||
@@ -339,7 +352,9 @@ String _firstText(Map<String, String> locales, Object? fallback, [List<Map<Strin
|
||||
}
|
||||
|
||||
String _genreText(List<Map<String, String>> types, Object? fallback) {
|
||||
final names = types.map((item) => item['zh'] ?? '').where((value) => value.isNotEmpty);
|
||||
final names = types
|
||||
.map((item) => item['zh'] ?? '')
|
||||
.where((value) => value.isNotEmpty);
|
||||
final joined = names.join(' / ');
|
||||
if (joined.isNotEmpty) return joined;
|
||||
return fallback is String ? fallback : '';
|
||||
|
||||
@@ -150,8 +150,8 @@ abstract final class AppDimens {
|
||||
static const double recommendSectionTitle = 17;
|
||||
static const double recommendSectionSubtitle = 11;
|
||||
static const double recommendSectionMore = 12;
|
||||
// 封面 122*(4/3)≈163 + 间距6 + 两行标题39 + 间距6 + 标签13 ≈ 227。
|
||||
static const double recommendRowHeight = 228;
|
||||
// 封面 122*(4/3)≈163 + 间距6 + 两行标题≈42 + 间距6 + 标签≈13 ≈ 230,留 6 余量。
|
||||
static const double recommendRowHeight = 236;
|
||||
static const double recommendCardWidth = 122;
|
||||
|
||||
/// 首页热播榜。
|
||||
@@ -189,9 +189,9 @@ abstract final class AppDimens {
|
||||
static const double followingAddIconStroke = 1.5;
|
||||
static const double followingGridGap = 6;
|
||||
static const double followingGridRowGap = 12;
|
||||
// 单行标题 16*1.3=20.8 + 间距 6+4 + 副标题行高约 18.6 ≈ 49.4,留 0.6 余量;
|
||||
// 按 48 估算在副标题行高偏高的字体下会溢出 1~2px。
|
||||
static const double followingCardTextExtent = 50;
|
||||
|
||||
/// 追剧网格卡文字区:间距 6 + 两行剧名 16*1.3*2=41.6 ≈ 48,留 4 余量。
|
||||
static const double followingCardTextExtent = 52;
|
||||
static const double followingSubtitleFont = 12;
|
||||
static const double followingSubtitleGap = 4;
|
||||
static const double followingProgressHeight = 2;
|
||||
|
||||
+5
-4
@@ -316,6 +316,7 @@
|
||||
}
|
||||
},
|
||||
"episodes": "Episodes",
|
||||
"similarDramas": "More like this",
|
||||
"synopsisTitle": "Synopsis",
|
||||
"watchEpisodeOne": "Watch episode 1",
|
||||
"continueEpisode": "Continue episode {number}",
|
||||
@@ -432,7 +433,7 @@
|
||||
"joinVip": "Join VIP",
|
||||
"earnRewards": "Earn Rewards",
|
||||
"freeBonus": "Get Free Bonus",
|
||||
"watchHistory": "Watch History",
|
||||
"watchHistory": "History",
|
||||
"settings": "Settings",
|
||||
"serverAddress": "Server address",
|
||||
"serverSettings": "Server settings",
|
||||
@@ -492,7 +493,7 @@
|
||||
"progressError": "Your watch position could not be loaded.",
|
||||
"favoritesError": "Your favorites could not be loaded.",
|
||||
"favoriteToggleFailed": "Could not update favorites. Please retry.",
|
||||
"myFavorites": "My favorites",
|
||||
"myFavorites": "Favorites",
|
||||
"noFavorites": "No favorites yet.",
|
||||
"downloads": "Downloads",
|
||||
"downloadsError": "Download settings could not be loaded.",
|
||||
@@ -785,7 +786,7 @@
|
||||
"visitor": "Visitor",
|
||||
"copyUid": "Copy UID",
|
||||
"uidCopied": "UID copied",
|
||||
"offlineDownloads": "Offline downloads",
|
||||
"offlineDownloads": "Downloads",
|
||||
"watchPreferences": "Watch preferences",
|
||||
"contactSupport": "Contact support",
|
||||
"vipBrandName": "FruitShorts VIP",
|
||||
@@ -803,7 +804,7 @@
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"myFavorites": "My Favorites",
|
||||
"myFavorites": "Favorites",
|
||||
"manage": "Manage",
|
||||
"continueWatching": "Continue Watching",
|
||||
"continueWatchingSubtitle": "Episode {episode} of {total}",
|
||||
|
||||
+5
-4
@@ -295,6 +295,7 @@
|
||||
}
|
||||
},
|
||||
"episodes": "Episode",
|
||||
"similarDramas": "Rekomendasi Serupa",
|
||||
"synopsisTitle": "Sinopsis",
|
||||
"watchEpisodeOne": "Tonton episode 1",
|
||||
"continueEpisode": "Lanjut episode {number}",
|
||||
@@ -411,7 +412,7 @@
|
||||
"joinVip": "Gabung VIP",
|
||||
"earnRewards": "Dapatkan Hadiah",
|
||||
"freeBonus": "Bonus Gratis",
|
||||
"watchHistory": "Riwayat Tontonan",
|
||||
"watchHistory": "Riwayat",
|
||||
"settings": "Pengaturan",
|
||||
"serverAddress": "Alamat server",
|
||||
"serverSettings": "Pengaturan server",
|
||||
@@ -471,7 +472,7 @@
|
||||
"progressError": "Posisi tonton gagal dimuat.",
|
||||
"favoritesError": "Daftar favorit gagal dimuat.",
|
||||
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
|
||||
"myFavorites": "Favorit saya",
|
||||
"myFavorites": "Favorit",
|
||||
"noFavorites": "Belum ada favorit.",
|
||||
"downloads": "Unduhan",
|
||||
"downloadsError": "Pengaturan unduhan gagal dimuat.",
|
||||
@@ -747,7 +748,7 @@
|
||||
"visitor": "Pengunjung",
|
||||
"copyUid": "Salin UID",
|
||||
"uidCopied": "UID disalin",
|
||||
"offlineDownloads": "Unduhan offline",
|
||||
"offlineDownloads": "Unduhan",
|
||||
"watchPreferences": "Preferensi tontonan",
|
||||
"contactSupport": "Hubungi dukungan",
|
||||
"vipBrandName": "FruitShorts VIP",
|
||||
@@ -765,7 +766,7 @@
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"myFavorites": "Favorit Saya",
|
||||
"myFavorites": "Favorit",
|
||||
"manage": "Kelola",
|
||||
"continueWatching": "Lanjutkan",
|
||||
"continueWatchingSubtitle": "Episode {episode} dari {total}",
|
||||
|
||||
@@ -1276,6 +1276,12 @@ abstract class AppLocalizations {
|
||||
/// **'Episodes'**
|
||||
String get episodes;
|
||||
|
||||
/// No description provided for @similarDramas.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More like this'**
|
||||
String get similarDramas;
|
||||
|
||||
/// No description provided for @synopsisTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1771,7 +1777,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @watchHistory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch History'**
|
||||
/// **'History'**
|
||||
String get watchHistory;
|
||||
|
||||
/// No description provided for @settings.
|
||||
@@ -2131,7 +2137,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @myFavorites.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My Favorites'**
|
||||
/// **'Favorites'**
|
||||
String get myFavorites;
|
||||
|
||||
/// No description provided for @noFavorites.
|
||||
@@ -3415,7 +3421,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @offlineDownloads.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline downloads'**
|
||||
/// **'Downloads'**
|
||||
String get offlineDownloads;
|
||||
|
||||
/// No description provided for @watchPreferences.
|
||||
|
||||
@@ -644,6 +644,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
|
||||
@override
|
||||
String get similarDramas => 'More like this';
|
||||
|
||||
@override
|
||||
String get synopsisTitle => 'Synopsis';
|
||||
|
||||
@@ -903,7 +906,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get freeBonus => 'Get Free Bonus';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Watch History';
|
||||
String get watchHistory => 'History';
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
@@ -1089,7 +1092,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'Could not update favorites. Please retry.';
|
||||
|
||||
@override
|
||||
String get myFavorites => 'My Favorites';
|
||||
String get myFavorites => 'Favorites';
|
||||
|
||||
@override
|
||||
String get noFavorites => 'No favorites yet.';
|
||||
@@ -1783,7 +1786,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get uidCopied => 'UID copied';
|
||||
|
||||
@override
|
||||
String get offlineDownloads => 'Offline downloads';
|
||||
String get offlineDownloads => 'Downloads';
|
||||
|
||||
@override
|
||||
String get watchPreferences => 'Watch preferences';
|
||||
|
||||
@@ -645,6 +645,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get episodes => 'Episode';
|
||||
|
||||
@override
|
||||
String get similarDramas => 'Rekomendasi Serupa';
|
||||
|
||||
@override
|
||||
String get synopsisTitle => 'Sinopsis';
|
||||
|
||||
@@ -906,7 +909,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get freeBonus => 'Bonus Gratis';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Riwayat Tontonan';
|
||||
String get watchHistory => 'Riwayat';
|
||||
|
||||
@override
|
||||
String get settings => 'Pengaturan';
|
||||
@@ -1091,7 +1094,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
|
||||
|
||||
@override
|
||||
String get myFavorites => 'Favorit Saya';
|
||||
String get myFavorites => 'Favorit';
|
||||
|
||||
@override
|
||||
String get noFavorites => 'Belum ada favorit.';
|
||||
@@ -1785,7 +1788,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get uidCopied => 'UID disalin';
|
||||
|
||||
@override
|
||||
String get offlineDownloads => 'Unduhan offline';
|
||||
String get offlineDownloads => 'Unduhan';
|
||||
|
||||
@override
|
||||
String get watchPreferences => 'Preferensi tontonan';
|
||||
|
||||
@@ -638,6 +638,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get episodes => '选集';
|
||||
|
||||
@override
|
||||
String get similarDramas => '相似推荐';
|
||||
|
||||
@override
|
||||
String get synopsisTitle => '简介';
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"episodeCount": "{count} 集",
|
||||
"technicalDemoCount": "免费试看 · {count} 集",
|
||||
"episodes": "选集",
|
||||
"similarDramas": "相似推荐",
|
||||
"synopsisTitle": "简介",
|
||||
"watchEpisodeOne": "观看第 1 集",
|
||||
"continueEpisode": "续看第 {number} 集",
|
||||
|
||||
@@ -374,7 +374,7 @@ class _LicenseTile extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
license.dramaTitle,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
|
||||
@@ -23,7 +23,9 @@ const double _optionHorizontal = 5;
|
||||
const double _optionVertical = 5;
|
||||
const double _optionRunGap = 4;
|
||||
const double _cardGap = 14;
|
||||
const double _gridAspectRatio = 0.56;
|
||||
|
||||
/// 卡片比例:三列网格里要装下封面 + 两行剧名 + 元信息。
|
||||
const double _gridAspectRatio = 0.53;
|
||||
const double _groupFontSize = 13;
|
||||
const double _labelFontSize = 12;
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ class FilterResultCard extends StatelessWidget {
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('filter-title-${drama.id}'),
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
|
||||
@@ -167,18 +167,10 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
bool isEditing,
|
||||
Set<String> picked,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (index == controller.cards.length) {
|
||||
return FollowingAddCard(onTap: onGoHome);
|
||||
}
|
||||
final drama = controller.cards[index];
|
||||
final record = history.recordForDrama(drama.id);
|
||||
final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number;
|
||||
final watchedEpisode = record?.episodeNumber ?? 0;
|
||||
final total = drama.episodeCount;
|
||||
final subtitle = watchedEpisode > 0 && total > 0
|
||||
? l10n.watchedEpisodeTotal(watchedEpisode, total)
|
||||
: l10n.favoriteEpisodeProgress(episode, total);
|
||||
return FollowingGridCard(
|
||||
dramaId: drama.id,
|
||||
title: drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
||||
@@ -186,10 +178,7 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
viewCount: drama.viewCount,
|
||||
editing: isEditing,
|
||||
selected: isEditing ? picked.contains(drama.id) : null,
|
||||
subtitle: subtitle,
|
||||
// TODO(backend): Drama 增加 hasUpdate / latestEpisodeNumber / isFinished
|
||||
// 字段后,在此传 updateBadge(l10n.hasUpdateBadge)、红色更新副标题
|
||||
// (l10n.updatedToEpisode)或灰色完结副标题(l10n.finishedAllEpisodes)。
|
||||
// TODO(backend): Drama 增加 hasUpdate 字段后,在此传 updateBadge。
|
||||
onTap: () => _openOrToggle(context, drama.id, isEditing),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,21 +19,18 @@ class FollowingGridCard extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.posterUrl,
|
||||
required this.viewCount,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.progress,
|
||||
this.editing = false,
|
||||
this.selected,
|
||||
this.onRemove,
|
||||
this.updateBadge,
|
||||
this.subtitleColor,
|
||||
});
|
||||
|
||||
final String dramaId;
|
||||
final String title;
|
||||
final String posterUrl;
|
||||
final int viewCount;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final double? progress;
|
||||
final bool editing;
|
||||
@@ -44,9 +41,6 @@ class FollowingGridCard extends StatelessWidget {
|
||||
/// 待后端在 Drama 增加更新状态字段后由收藏网格传入。
|
||||
final String? updateBadge;
|
||||
|
||||
/// 副标题颜色;更新中用强调红,完结用默认灰。
|
||||
final Color? subtitleColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
@@ -165,7 +159,7 @@ class FollowingGridCard extends StatelessWidget {
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
@@ -175,17 +169,6 @@ class FollowingGridCard extends StatelessWidget {
|
||||
height: AppDimens.cardTitleLineHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.followingSubtitleGap),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
color: subtitleColor ?? AppColors.textTertiary,
|
||||
fontSize: AppDimens.followingCardSubtitleFontNew,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,7 +22,9 @@ const int _moreLimit = 12;
|
||||
const int _filmHoles = 8;
|
||||
const double _cardPadding = 8;
|
||||
const double _cardWidth = 124;
|
||||
const double _rowHeight = 204;
|
||||
|
||||
/// 横向卡片行高:封面 144 + 两行剧名 + 上映日期。
|
||||
const double _rowHeight = 226;
|
||||
const double _cardRadius = 12;
|
||||
const double _loadMoreThreshold = 200;
|
||||
|
||||
@@ -173,9 +175,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
child: SizedBox(height: 14),
|
||||
),
|
||||
if (more.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _moreHeader(l10n),
|
||||
),
|
||||
SliverToBoxAdapter(child: _moreHeader(l10n)),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 10),
|
||||
),
|
||||
@@ -467,7 +467,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
|
||||
|
||||
@@ -189,9 +189,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
_thumbnailService = PlaybackThumbnailService(
|
||||
sourceResolver: _resolveThumbnailSource,
|
||||
);
|
||||
// 雪碧图每张解码后是整块位图,保留 3 部剧足够擦洗回退,8 部在低端机
|
||||
// 容易把图片缓存顶满甚至触发 OOM。
|
||||
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 3)
|
||||
// 容量必须盖住「同时预取的数量」:渲染栈有上一集/当前集/下一集三个槽位,
|
||||
// 每槽预取当前 cue + 1 个相邻 cue,约 6 张。容量小于这个数时,每次重建都会
|
||||
// 把别处刚解码的雪碧图挤掉,形成「重建→重新下载解码→notify→重建」的死循环。
|
||||
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
|
||||
..addListener(_handleSpriteFrameChanged);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
logic.start(
|
||||
@@ -1013,19 +1014,19 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final thumbnailReady =
|
||||
thumbnailsUrl != null &&
|
||||
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
|
||||
if (!locked && !posterReady && thumbnailsUrl != null) {
|
||||
// 只在没命中时预取:prepareAt 会触发下载解码并 notifyListeners -> 重建,
|
||||
// 无条件调用会让每次重建都排一次异步任务。
|
||||
if (!locked && !posterReady && !thumbnailReady && thumbnailsUrl != null) {
|
||||
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
|
||||
}
|
||||
if (selected &&
|
||||
!locked &&
|
||||
videoPort != null &&
|
||||
videoPort.controller.value.isInitialized) {
|
||||
unawaited(
|
||||
_spriteAxisService.prepareAt(
|
||||
thumbnailsUrl,
|
||||
videoPort.controller.value.position,
|
||||
),
|
||||
);
|
||||
final position = videoPort.controller.value.position;
|
||||
if (_spriteAxisService.cachedFrameAt(thumbnailsUrl, position) == null) {
|
||||
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, position));
|
||||
}
|
||||
}
|
||||
final pagePosition = indices.indexOf(index);
|
||||
return PlayerRenderSlotData(
|
||||
@@ -1286,7 +1287,8 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 56,
|
||||
// 进度条触控带 16pt + 8pt 间距,退出清屏按钮贴着进度条上方。
|
||||
bottom: 24,
|
||||
child: ExitClearScreenButton(
|
||||
key: const Key('home-feed-exit-clear-screen'),
|
||||
tooltip: l10n.exitClearScreen,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const List<Shadow> _iconShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
];
|
||||
|
||||
/// Shared floating action used to leave clear-screen mode.
|
||||
class ExitClearScreenButton extends StatelessWidget {
|
||||
const ExitClearScreenButton({
|
||||
@@ -25,11 +29,15 @@ class ExitClearScreenButton extends StatelessWidget {
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.controlScrim,
|
||||
backgroundColor: AppColors.transparent,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
icon: const Icon(Icons.fullscreen_exit_rounded, size: 18),
|
||||
icon: const Icon(
|
||||
Icons.fullscreen_exit_rounded,
|
||||
size: 22,
|
||||
shadows: _iconShadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,13 +21,16 @@ import 'scrub_preview_card.dart';
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
const double _progressHeight = 2;
|
||||
const double _progressHitHeight = 32;
|
||||
|
||||
/// 进度条触控带高度:没有滑块圆点后收到 16pt,够手指按住这条线拖动。
|
||||
const double _progressHitHeight = 16;
|
||||
const double _pageHorizontal = 20;
|
||||
const double _railRight = 14;
|
||||
const double _railGap = 12;
|
||||
// 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。
|
||||
// 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。
|
||||
const double _bottomInfoInset = homeFeedWatchAllHeight + 16;
|
||||
// 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。
|
||||
const double _watchAllBottomInset = 8;
|
||||
// 信息面板底边直接贴住观看条顶沿,中间不留缝。
|
||||
const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight;
|
||||
const double _railBottomOffset = 40;
|
||||
const Duration _overlayFadeDuration = Duration(milliseconds: 120);
|
||||
const List<Shadow> _boostTextShadows = [
|
||||
@@ -152,7 +155,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: homeFeedWatchAllHeight + 12,
|
||||
bottom: _watchAllBottomInset + homeFeedWatchAllHeight + 12,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.boosting ? 1 : 0,
|
||||
@@ -162,10 +165,30 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 观看条在下、进度条在上:进度条 32pt 触控盒要盖住观看条下沿的空白,
|
||||
// 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _pageHorizontal,
|
||||
bottom: homeFeedWatchAllHeight,
|
||||
bottom: _watchAllBottomInset,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.visible && !_scrubbing && !widget.boosting
|
||||
? 1
|
||||
: 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.visible || _scrubbing || widget.boosting,
|
||||
child: HomeFeedWatchAllBar(
|
||||
drama: widget.drama,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _pageHorizontal,
|
||||
bottom: 0,
|
||||
child: _FeedProgressBar(
|
||||
controller: widget.controller,
|
||||
locked: widget.locked,
|
||||
@@ -182,27 +205,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
onSeek: widget.onSeek,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.visible && !_scrubbing && !widget.boosting
|
||||
? 1
|
||||
: 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.visible || _scrubbing || widget.boosting,
|
||||
child: HomeFeedWatchAllBar(
|
||||
drama: widget.drama,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 信息面板放在最后(最上层):进度条 32px 触控盒向上伸进底部避让区,
|
||||
// 若面板在前,简介/展开按钮底部的触控留白会被进度条吞掉点击。
|
||||
// 视觉上避让条带为空,提层不改变观感。
|
||||
// 信息面板放在最后(最上层),底部避让区永远优先响应简介/展开按钮的点击。
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _railRight,
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
part of 'home_feed_overlay.dart';
|
||||
|
||||
const double _thumbSize = 6;
|
||||
const double _thumbActiveSize = 14;
|
||||
const double _trackActiveHeight = 6;
|
||||
const double _lineCenterFromBottom = 8;
|
||||
// 进度线贴住屏幕底部(线心距底 4pt);播放进度只靠蓝色已播段表达,不画滑块圆点。
|
||||
const double _lineCenterFromBottom = 4;
|
||||
const double _timeBubbleWidth = 96;
|
||||
const double _timeBubbleMargin = 12;
|
||||
const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
|
||||
@@ -305,7 +304,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
height: widget.height,
|
||||
child: ColoredBox(
|
||||
key: _inactiveTrackKey,
|
||||
color: AppColors.border,
|
||||
color: AppColors.inactiveTrack,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -345,20 +344,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
Widget _track(double fraction, double width) {
|
||||
final dragging = _preview != null;
|
||||
final trackHeight = dragging ? _trackActiveHeight : widget.height;
|
||||
final size = dragging ? _thumbActiveSize : _thumbSize;
|
||||
final finger = (width * fraction).clamp(0.0, width).toDouble();
|
||||
final thumbSlotLeft = dragging
|
||||
? finger - _thumbActiveSize / 2
|
||||
: (finger - _thumbActiveSize / 2)
|
||||
.clamp(0.0, width - _thumbActiveSize)
|
||||
.toDouble();
|
||||
final playedWidth = dragging ? finger : width * fraction;
|
||||
final playedWidth = (width * fraction).clamp(0.0, width).toDouble();
|
||||
final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2;
|
||||
final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2;
|
||||
return SizedBox(
|
||||
height: widget.hitHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
@@ -374,7 +364,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
key: const Key('home-feed-progress-track'),
|
||||
width: width,
|
||||
height: trackHeight,
|
||||
color: AppColors.border,
|
||||
// 和剧集播放页进度条共用同一个槽底色,两个入口观感一致。
|
||||
color: AppColors.inactiveTrack,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
@@ -390,24 +381,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: thumbSlotLeft,
|
||||
bottom: thumbSlotBottom,
|
||||
child: SizedBox.square(
|
||||
dimension: _thumbActiveSize,
|
||||
child: Center(
|
||||
child: Container(
|
||||
key: const Key('home-feed-progress-thumb'),
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,7 +13,14 @@ import 'interaction_rail.dart';
|
||||
const double _seriesFont = 17;
|
||||
const double _tagFont = 12;
|
||||
const double _synopsisFont = 14;
|
||||
const double _watchAllHeight = 44;
|
||||
const double _watchAllHeight = 38;
|
||||
|
||||
/// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
|
||||
const double _watchAllInset = 12;
|
||||
const double _watchAllRadius = 12;
|
||||
|
||||
/// 标题、标签、简介之间的行间距:底部信息本来就挤,留 4pt 够分层。
|
||||
const double _blockGap = 4;
|
||||
const List<Shadow> _textShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
];
|
||||
@@ -57,6 +64,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
|
||||
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
||||
final genres = _genreValues(Localizations.localeOf(context).languageCode);
|
||||
final tags = _tagChips(l10n, genres);
|
||||
return Column(
|
||||
key: const Key('home-feed-info-panel'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -104,18 +112,21 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_tagMetaRow(l10n, genres),
|
||||
// 没有标签就整行跳过,不给标题和简介之间留空档。
|
||||
if (tags.isNotEmpty) ...[
|
||||
const SizedBox(height: _blockGap),
|
||||
_tagRow(tags),
|
||||
],
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: _blockGap),
|
||||
_synopsis(l10n, synopsis),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
|
||||
final chips = <Widget>[
|
||||
List<Widget> _tagChips(AppLocalizations l10n, List<String> genres) {
|
||||
return [
|
||||
if (isHotDrama(widget.drama))
|
||||
_TagChip(
|
||||
key: const Key('home-feed-hot-badge'),
|
||||
@@ -125,6 +136,9 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
for (final genre in genres)
|
||||
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _tagRow(List<Widget> chips) {
|
||||
return SizedBox(
|
||||
key: const Key('home-feed-tags'),
|
||||
height: 22,
|
||||
@@ -149,10 +163,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
);
|
||||
final span = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: prefix,
|
||||
style: baseStyle,
|
||||
),
|
||||
TextSpan(text: prefix, style: baseStyle),
|
||||
TextSpan(
|
||||
text: ' | ',
|
||||
style: baseStyle.copyWith(
|
||||
@@ -368,7 +379,7 @@ class _TagChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部通栏条:🎬 观看完整剧集 全 N 集。
|
||||
/// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。
|
||||
class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
const HomeFeedWatchAllBar({
|
||||
super.key,
|
||||
@@ -382,42 +393,45 @@ class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_rounded,
|
||||
size: 22,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_watchAllRadius),
|
||||
child: Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_rounded,
|
||||
size: 22,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,7 +6,9 @@ import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const double _toggleSize = 68;
|
||||
const double _iconSize = 36;
|
||||
const Color _toggleBackground = AppColors.controlSoftScrim;
|
||||
|
||||
/// 播放/暂停压在画面上,垫一层半透明深色圆底保证亮画面下也能看见。
|
||||
const Color _toggleBackground = Color(0x66000000);
|
||||
const List<Shadow> _iconShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 12),
|
||||
];
|
||||
|
||||
@@ -19,8 +19,17 @@ import 'seek_progress_bar.dart';
|
||||
|
||||
part 'player_overlay_parts.dart';
|
||||
|
||||
const double _topButtonSize = 36;
|
||||
const double _topButtonSize = 40;
|
||||
const double _topBarHeight = 58;
|
||||
|
||||
/// 底部信息、选集条、进度条之间的统一行距。
|
||||
const double _blockGap = 8;
|
||||
|
||||
/// 底部选集条与右侧清屏按钮的统一高度,两者必须等高。
|
||||
const double _bottomActionHeight = 34;
|
||||
|
||||
/// 选集条内部左右留白。
|
||||
const double _bottomActionInset = 10;
|
||||
const double _boostHintIconSize = 16;
|
||||
const double _boostHintFontSize = 13;
|
||||
const double _synopsisRailInset = 54;
|
||||
@@ -107,11 +116,12 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
key: const Key('player-back'),
|
||||
tooltip: data.l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
iconSize: 30,
|
||||
onPressed: actions.onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: _topButtonSize),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.l10n.episodeLabel(data.episode.number),
|
||||
@@ -135,7 +145,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
_TopButton(
|
||||
key: const Key('player-more'),
|
||||
tooltip: data.l10n.more,
|
||||
icon: Icons.more_vert_rounded,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
onPressed: actions.onMore,
|
||||
),
|
||||
],
|
||||
@@ -226,19 +236,20 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
child: _bottomInfo(context),
|
||||
),
|
||||
if (data.boosting) _boostHint(),
|
||||
_bottomProgress(),
|
||||
// 加速时收起底栏。拖动进度时底栏只隐藏,占位保留,进度条不挪位。
|
||||
// 加速时收起选集条。拖动进度时只隐藏,占位保留,底部不跳动。
|
||||
scrubbing
|
||||
? IgnorePointer(
|
||||
child: Opacity(opacity: 0, child: _bottomActions()),
|
||||
)
|
||||
: Offstage(offstage: data.boosting, child: _bottomActions()),
|
||||
// 进度条压在最底部,和首页 feed 一致。
|
||||
_bottomProgress(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加速提示:橙色小三角 + 当前倍速文案,悬在进度条上方。
|
||||
/// 加速提示:橙色小三角 + 当前倍速文案,压在底部信息区下方。
|
||||
Widget _boostHint() {
|
||||
return Padding(
|
||||
key: const Key('player-speed-boost-hint'),
|
||||
@@ -270,25 +281,28 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
Widget _bottomInfo(BuildContext context) {
|
||||
final series = localizedDramaTitle(data.l10n, data.drama);
|
||||
final synopsis = localizedDramaSynopsis(data.l10n, data.drama);
|
||||
// 右侧让出互动栏的宽度:剧名和简介一样,收在互动栏左侧,长标题也不会钻到按钮下面。
|
||||
return Padding(
|
||||
key: const Key('player-controls-bottom'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 14, 0),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
14,
|
||||
_blockGap,
|
||||
14 + _synopsisRailInset,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_detailRow(series),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: _synopsisRailInset),
|
||||
child: _EpisodeSynopsis(
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
drama: data.drama,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
),
|
||||
_EpisodeSynopsis(
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
drama: data.drama,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -296,9 +310,10 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _bottomProgress() {
|
||||
// 进度条不贴底:底部留 16pt,线心离底边更远,也不再和选集条挤在一起。
|
||||
return Padding(
|
||||
key: const Key('player-progress-container'),
|
||||
padding: EdgeInsets.fromLTRB(14, controlsVisible ? 2 : 8, 14, 0),
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 16),
|
||||
child: SeekProgressBar(
|
||||
key: const Key('player-progress'),
|
||||
valueListenable: data.controller,
|
||||
@@ -345,10 +360,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.playerMuted,
|
||||
color: AppColors.onPrimary,
|
||||
size: 20,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
@@ -362,55 +376,63 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
Widget _bottomActions() {
|
||||
return Padding(
|
||||
key: const Key('player-bottom-actions'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 6),
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Material(
|
||||
key: const Key('player-episodes-pill'),
|
||||
color: AppColors.playerOverlayPill,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: const Key('player-episodes'),
|
||||
onTap: actions.onEpisodes,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${data.l10n.episodes} · '
|
||||
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
|
||||
'${data.l10n.freeWatch}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
// 清屏后只留右下角的退出按钮,左侧选集条一起收起。
|
||||
if (!controlsVisible)
|
||||
const Spacer()
|
||||
else ...[
|
||||
Expanded(
|
||||
// 选集条给一层浅底色:视频画面亮时纯文字不好认,也顺带标出可点区域。
|
||||
child: Material(
|
||||
key: const Key('player-episodes-pill'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: const Key('player-episodes'),
|
||||
onTap: actions.onEpisodes,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _bottomActionInset,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: _bottomActionHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${data.l10n.episodes} · '
|
||||
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
|
||||
'${data.l10n.freeWatch}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
size: 18,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 18,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
_BottomIconButton(
|
||||
key: const Key('player-bottom-clear-screen'),
|
||||
tooltip: controlsVisible
|
||||
@@ -419,6 +441,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
icon: controlsVisible
|
||||
? Icons.fullscreen_rounded
|
||||
: Icons.fullscreen_exit_rounded,
|
||||
height: _bottomActionHeight,
|
||||
onPressed: controlsVisible ? actions.onClear : actions.onExitClear,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
part of 'player_overlay.dart';
|
||||
|
||||
/// 顶部按钮:直接压在画面上,不加底色,靠文字/图标阴影保证可读性。
|
||||
class _TopButton extends StatelessWidget {
|
||||
const _TopButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.iconSize = 22,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,11 +25,11 @@ class _TopButton extends StatelessWidget {
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.controlScrim,
|
||||
backgroundColor: AppColors.transparent,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 18),
|
||||
icon: Icon(icon, size: iconSize, shadows: _playerTextShadows),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -41,7 +44,7 @@ class _SpeedEntry extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.controlScrim,
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -81,25 +84,30 @@ class _BottomIconButton extends StatelessWidget {
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.height = 40,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// 与左侧选集条等高,两个控件底部齐平。
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: 40,
|
||||
return SizedBox(
|
||||
width: 40,
|
||||
height: height,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.playerOverlayPill,
|
||||
backgroundColor: AppColors.transparent,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 22),
|
||||
icon: Icon(icon, size: 24, shadows: _playerTextShadows),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -242,8 +250,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
_toggle(widget.l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6)),
|
||||
_toggle(
|
||||
widget.l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -291,12 +301,12 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
|
||||
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
|
||||
TextSpan _separatorSpan() => TextSpan(
|
||||
text: ' | ',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
text: ' | ',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggle(String label, {required EdgeInsets padding}) {
|
||||
return GestureDetector(
|
||||
|
||||
@@ -321,10 +321,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
trackHeight: trackHeight,
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.inactiveTrackColor,
|
||||
thumbColor: AppColors.onPrimary,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
),
|
||||
// 播放页进度条不画滑块圆点:进度只靠蓝色已播段表达。
|
||||
thumbShape: SliderComponentShape.noThumb,
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
|
||||
@@ -464,7 +464,7 @@ class _RankingRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
|
||||
@@ -138,8 +138,7 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openDrama(Drama drama) =>
|
||||
playDramaFromCatalog(context, drama);
|
||||
Future<void> _openDrama(Drama drama) => playDramaFromCatalog(context, drama);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -163,9 +162,7 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
|
||||
Positioned.fill(
|
||||
child: categoryList.isEmpty
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.brandRed,
|
||||
),
|
||||
child: CircularProgressIndicator(color: AppColors.brandRed),
|
||||
)
|
||||
: PageView.builder(
|
||||
key: const Key('recommend-channel-pager'),
|
||||
@@ -184,7 +181,8 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
|
||||
carouselActive: widget.active && index == selectedIndex,
|
||||
mediaTop: mediaTop,
|
||||
onRequest: _ensureCategory,
|
||||
onRefresh: () => _refreshCategory(category.id, index == 0),
|
||||
onRefresh: () =>
|
||||
_refreshCategory(category.id, index == 0),
|
||||
onOpenDrama: _openDrama,
|
||||
);
|
||||
},
|
||||
@@ -294,11 +292,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
List<Drama> newArrivals = const [];
|
||||
List<Drama> rest = dramas;
|
||||
if (widget.isFeatured && dramas.isNotEmpty) {
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
final badge = AppLocalizations.of(context)!.newArrivalBadge;
|
||||
newArrivals = dramas
|
||||
.where((drama) => _isNewArrivalTag(drama, lang, badge))
|
||||
.toList(growable: false);
|
||||
newArrivals = dramas.where(isNewArrivalTag).toList(growable: false);
|
||||
if (newArrivals.isNotEmpty) {
|
||||
final newIds = newArrivals.map((drama) => drama.id).toSet();
|
||||
rest = dramas
|
||||
@@ -326,7 +320,9 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showCarousel ? 0 : widget.mediaTop + RecommendChannelNav.height,
|
||||
top: showCarousel
|
||||
? 0
|
||||
: widget.mediaTop + RecommendChannelNav.height,
|
||||
),
|
||||
child: Center(child: _placeholder(slot.loading)),
|
||||
),
|
||||
@@ -334,12 +330,11 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
);
|
||||
} else if (widget.isFeatured) {
|
||||
// 无轮播图时内容需避开顶部悬浮导航。
|
||||
final contentTop =
|
||||
showCarousel ? 24.0 : widget.mediaTop + RecommendChannelNav.height + 16;
|
||||
final contentTop = showCarousel
|
||||
? 24.0
|
||||
: widget.mediaTop + RecommendChannelNav.height + 16;
|
||||
if (newArrivals.isNotEmpty) {
|
||||
sliverList.add(
|
||||
SliverToBoxAdapter(child: SizedBox(height: contentTop)),
|
||||
);
|
||||
sliverList.add(SliverToBoxAdapter(child: SizedBox(height: contentTop)));
|
||||
sliverList.add(
|
||||
SliverToBoxAdapter(
|
||||
child: HomeNewArrivalRow(
|
||||
@@ -348,9 +343,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
),
|
||||
),
|
||||
);
|
||||
sliverList.add(
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 28)),
|
||||
);
|
||||
sliverList.add(const SliverToBoxAdapter(child: SizedBox(height: 28)));
|
||||
}
|
||||
sliverList.add(
|
||||
_gridSliver(
|
||||
@@ -363,7 +356,9 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
sliverList.add(
|
||||
_gridSliver(
|
||||
dramas,
|
||||
topPadding: showCarousel ? 20 : widget.mediaTop + RecommendChannelNav.height + 16,
|
||||
topPadding: showCarousel
|
||||
? 20
|
||||
: widget.mediaTop + RecommendChannelNav.height + 16,
|
||||
crossAxisCount: 3,
|
||||
),
|
||||
);
|
||||
@@ -427,7 +422,6 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
const pageSide = 14.0;
|
||||
const cardGap = 9.0;
|
||||
const rowGap = 16.0;
|
||||
const cardBodyExtent = 44.0; // 封面下间距 + 剧名行 + 集数行
|
||||
// 两列时卡更大,卡间距略放宽。
|
||||
final spacing = crossAxisCount == 2 ? 12.0 : cardGap;
|
||||
return SliverPadding(
|
||||
@@ -437,7 +431,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
final itemWidth =
|
||||
(constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) /
|
||||
crossAxisCount;
|
||||
final itemExtent = itemWidth * 157 / 110 + cardBodyExtent;
|
||||
final itemExtent = itemWidth * 157 / 110 + homePosterCardBodyExtent;
|
||||
return SliverGrid(
|
||||
key: Key('recommend-category-grid-${widget.categoryId}'),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
@@ -446,17 +440,14 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
mainAxisSpacing: rowGap,
|
||||
mainAxisExtent: itemExtent,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final drama = dramas[index];
|
||||
return HomePosterCard(
|
||||
key: Key('recommend-category-card-${drama.id}'),
|
||||
drama: drama,
|
||||
onTap: () => widget.onOpenDrama(drama),
|
||||
);
|
||||
},
|
||||
childCount: dramas.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final drama = dramas[index];
|
||||
return HomePosterCard(
|
||||
key: Key('recommend-category-card-${drama.id}'),
|
||||
drama: drama,
|
||||
onTap: () => widget.onOpenDrama(drama),
|
||||
);
|
||||
}, childCount: dramas.length),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -464,14 +455,11 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定接口下发的短剧标签是否为「上新」。
|
||||
/// 「上新」标签语义码,对应后台 content_tag.code。
|
||||
const String newArrivalTagCode = 'NEW_ARRIVAL';
|
||||
|
||||
/// 判定短剧是否属于「上新」板块。
|
||||
///
|
||||
/// 完全以后端返回的 `tag` / `tagLocales`(经 [Drama.localizedTag] 本地化)
|
||||
/// 为准;兼容「上新 / NEW / BARU」三种文案,并兼容「本周上新」这类带前后缀的写法。
|
||||
bool _isNewArrivalTag(Drama drama, String lang, String localizedBadge) {
|
||||
final tag = drama.localizedTag(lang).trim();
|
||||
if (tag.isEmpty) return false;
|
||||
if (tag == localizedBadge) return true;
|
||||
final lower = tag.toLowerCase();
|
||||
return tag.contains('上新') || lower == 'new' || lower == 'baru';
|
||||
}
|
||||
/// 只看语义码:码由后台标签管理配置,文案怎么改、切什么语言都不影响。
|
||||
bool isNewArrivalTag(Drama drama) =>
|
||||
drama.tagCode.trim().toUpperCase() == newArrivalTagCode;
|
||||
@@ -46,15 +46,25 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
static const double _dotGap = 9;
|
||||
static const double _ctaHeight = 38;
|
||||
|
||||
final PageController _controller = PageController();
|
||||
/// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。
|
||||
static const int _basePage = 1000;
|
||||
|
||||
late final PageController _controller;
|
||||
Timer? _timer;
|
||||
int _page = 0;
|
||||
int _index = 0;
|
||||
bool _pressed = false;
|
||||
bool _tickerEnabled = true;
|
||||
|
||||
/// 起始页码取 banner 数量的大倍数,向左右滑都不会到头。
|
||||
static int _initialPageOf(int bannerCount) =>
|
||||
bannerCount > 1 ? bannerCount * _basePage : 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_page = _initialPageOf(widget.banners.length);
|
||||
_controller = PageController(initialPage: _page);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer());
|
||||
}
|
||||
|
||||
@@ -72,8 +82,11 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
void didUpdateWidget(covariant HomeHeroCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.banners.length != widget.banners.length) {
|
||||
if (_index >= widget.banners.length) _index = 0;
|
||||
_syncTimer();
|
||||
// 首屏 banner 是异步到的:列表从空变有之后要把页码挪到虚拟起点,
|
||||
// 否则左滑第一下就到头了。
|
||||
_page = _initialPageOf(widget.banners.length);
|
||||
_index = 0;
|
||||
_jumpToBasePage();
|
||||
}
|
||||
if (oldWidget.banners.length != widget.banners.length ||
|
||||
oldWidget.active != widget.active) {
|
||||
@@ -91,7 +104,8 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
void _syncTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
final canAdvance = mounted &&
|
||||
final canAdvance =
|
||||
mounted &&
|
||||
widget.active &&
|
||||
_tickerEnabled &&
|
||||
!_pressed &&
|
||||
@@ -102,12 +116,16 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
|
||||
void _advance() {
|
||||
if (!_controller.hasClients || widget.banners.length < 2) return;
|
||||
final next = (_index + 1) % widget.banners.length;
|
||||
_controller.animateToPage(
|
||||
next,
|
||||
duration: _pageDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
// 虚拟页码无限,直接翻下一页即可,不用在末尾跳回第 0 页。
|
||||
_controller.nextPage(duration: _pageDuration, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
/// 把 PageView 静默挪到虚拟起点:视觉上是同一张图,不会闪。
|
||||
void _jumpToBasePage() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_controller.hasClients) return;
|
||||
if (_controller.page?.round() != _page) _controller.jumpToPage(_page);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -134,9 +152,17 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: widget.banners.length,
|
||||
onPageChanged: (value) => setState(() => _index = value),
|
||||
itemBuilder: (context, index) => _bannerPage(context, index),
|
||||
// 数量大于 1 时用无限页码做无缝循环,取模映射到真实 banner。
|
||||
itemCount: widget.banners.length > 1
|
||||
? null
|
||||
: widget.banners.length,
|
||||
onPageChanged: (value) => setState(() {
|
||||
_page = value;
|
||||
final count = widget.banners.length;
|
||||
_index = count == 0 ? 0 : value % count;
|
||||
}),
|
||||
itemBuilder: (context, page) =>
|
||||
_bannerPage(context, page % widget.banners.length),
|
||||
),
|
||||
Positioned(left: 0, right: 0, bottom: 14, child: _dots(context)),
|
||||
],
|
||||
@@ -288,16 +314,18 @@ class _HeroCopy extends StatelessWidget {
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.95),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: const [
|
||||
Shadow(color: Color(0x80000000), blurRadius: 8),
|
||||
],
|
||||
shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_WatchButton(height: ctaHeight, label: l10n.watchNowAction, onTap: onWatch),
|
||||
_WatchButton(
|
||||
height: ctaHeight,
|
||||
label: l10n.watchNowAction,
|
||||
onTap: onWatch,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_FavoriteCircle(size: ctaHeight, drama: drama),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,12 @@ const double _cardCoverHeight = 157;
|
||||
const double _cardGap = 9;
|
||||
const double _cardRadius = 12;
|
||||
|
||||
/// 海报卡封面以下的高度:上间距 8 + 两行剧名 + 2 + 集数行 + 余量。
|
||||
///
|
||||
/// 横向列表和网格都要给一个有限的卡片高度,这里是唯一来源,
|
||||
/// 两行剧名撑高时只改这一处,不会出现某个入口高度不够而溢出。
|
||||
const double homePosterCardBodyExtent = 62;
|
||||
|
||||
/// 上新横滑卡板块。无板块标题,卡片左上角带「上新」角标。
|
||||
/// 内层横向 ListView 会自行识别横向手势,不与外层纵向滚动冲突。
|
||||
class HomeNewArrivalRow extends StatelessWidget {
|
||||
@@ -32,8 +38,7 @@ class HomeNewArrivalRow extends StatelessWidget {
|
||||
if (dramas.isEmpty) return const SizedBox.shrink();
|
||||
return SizedBox(
|
||||
key: const Key('home-new-arrival-row'),
|
||||
height:
|
||||
_cardCoverHeight + 8 + 20 + 2 + 16, // 封面 + 剧名行 + 集数行 + 余量
|
||||
height: _cardCoverHeight + homePosterCardBodyExtent,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _pageSide),
|
||||
@@ -130,7 +135,7 @@ class HomePosterCard extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
drama.localizedTitle(lang),
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
@@ -192,7 +197,11 @@ class HomeEditorPickBanner extends StatelessWidget {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xE6000000), Color(0x4D000000), Color(0x1A000000)],
|
||||
colors: [
|
||||
Color(0xE6000000),
|
||||
Color(0x4D000000),
|
||||
Color(0x1A000000),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -293,7 +293,10 @@ void main() {
|
||||
expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('home-feed-synopsis-expanded')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('home-feed-synopsis-expanded')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded')))
|
||||
@@ -371,7 +374,7 @@ void main() {
|
||||
find.byKey(const Key('home-feed-progress-inactive')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsNothing);
|
||||
await tester.tap(find.byKey(const Key('rail-share')));
|
||||
expect(shares, 1);
|
||||
});
|
||||
@@ -575,26 +578,36 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final bar = tester.getRect(find.byKey(const Key('home-feed-full-series')));
|
||||
expect(bar.height, 44);
|
||||
expect(bar.height, 38);
|
||||
final overlayRect = tester.getRect(
|
||||
find.byKey(const Key('home-feed-gesture')),
|
||||
);
|
||||
// 通栏观看条铺满浮层宽度,不再左右内缩。
|
||||
expect(bar.left, closeTo(overlayRect.left, 0.1));
|
||||
expect(bar.width, closeTo(overlayRect.width, 0.1));
|
||||
// 进度线中心比观看条顶沿高 8pt。
|
||||
// 观看条与底部进度条共用同一组左右边距(各 20pt),宽度一致。
|
||||
expect(bar.left, closeTo(overlayRect.left + 20, 0.1));
|
||||
expect(bar.width, closeTo(overlayRect.width - 40, 0.1));
|
||||
expect(
|
||||
bar.top -
|
||||
tester
|
||||
.getCenter(find.byKey(const Key('home-feed-progress-track')))
|
||||
.dy,
|
||||
closeTo(8, 0.1),
|
||||
bar.left,
|
||||
closeTo(
|
||||
tester.getRect(find.byKey(const Key('home-feed-progress'))).left,
|
||||
0.1,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
bar.width,
|
||||
closeTo(
|
||||
tester.getRect(find.byKey(const Key('home-feed-progress'))).width,
|
||||
0.1,
|
||||
),
|
||||
);
|
||||
// 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。
|
||||
final trackCenter = tester.getCenter(
|
||||
find.byKey(const Key('home-feed-progress-track')),
|
||||
);
|
||||
expect(overlayRect.bottom - trackCenter.dy, closeTo(4, 0.1));
|
||||
expect(trackCenter.dy, greaterThan(bar.bottom));
|
||||
expect(
|
||||
tester
|
||||
.widget<Material>(
|
||||
find.byKey(const Key('home-feed-full-series-bg')),
|
||||
)
|
||||
.widget<Material>(find.byKey(const Key('home-feed-full-series-bg')))
|
||||
.color,
|
||||
AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
);
|
||||
@@ -602,13 +615,9 @@ void main() {
|
||||
tester.getRect(find.textContaining('Watch full series')).center.dy,
|
||||
closeTo(bar.center.dy, 2),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||
const Size(6, 6),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress'))).height,
|
||||
greaterThanOrEqualTo(32),
|
||||
greaterThanOrEqualTo(16),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
@@ -620,19 +629,6 @@ void main() {
|
||||
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||
.center
|
||||
.dy;
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||
.center
|
||||
.dy,
|
||||
closeTo(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||
.center
|
||||
.dy,
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||
@@ -704,10 +700,6 @@ void main() {
|
||||
closeTo(trackRect.right - 12, 0.1),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||
const Size(14, 14),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
|
||||
6,
|
||||
@@ -719,19 +711,6 @@ void main() {
|
||||
.dy,
|
||||
closeTo(idleTrackCenter, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||
.center
|
||||
.dy,
|
||||
closeTo(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||
.center
|
||||
.dy,
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(scrubVisibility.last, isFalse);
|
||||
@@ -746,10 +725,6 @@ void main() {
|
||||
);
|
||||
expect(seeked, isNotNull);
|
||||
expect(seeked!.inSeconds, greaterThan(0));
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||
const Size(6, 6),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
|
||||
2,
|
||||
@@ -825,11 +800,11 @@ void main() {
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsNothing);
|
||||
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
|
||||
|
||||
controller.value = const VideoPlayerValue(
|
||||
@@ -838,7 +813,7 @@ void main() {
|
||||
isInitialized: true,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||
@@ -853,7 +828,7 @@ void main() {
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
|
||||
controller.value = const VideoPlayerValue(
|
||||
@@ -872,7 +847,7 @@ void main() {
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
});
|
||||
|
||||
|
||||
@@ -159,6 +159,31 @@ void main() {
|
||||
expect(loader.spriteCalls, hasLength(6));
|
||||
});
|
||||
|
||||
testWidgets('should_not_reload_three_slots_sprites_when_capacity_fits', (
|
||||
tester,
|
||||
) async {
|
||||
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
|
||||
// 播放页三个槽位各预取 2 张,容量必须 >= 6,否则每轮重建都会重新下载解码。
|
||||
final service = SpriteThumbnailAxisService(
|
||||
loader: loader,
|
||||
spriteCacheCapacity: 8,
|
||||
);
|
||||
addTearDown(service.dispose);
|
||||
const urls = [
|
||||
'https://cdn.test/episode-0/thumbnails.vtt',
|
||||
'https://cdn.test/episode-1/thumbnails.vtt',
|
||||
'https://cdn.test/episode-2/thumbnails.vtt',
|
||||
];
|
||||
|
||||
for (var round = 0; round < 3; round++) {
|
||||
for (final url in urls) {
|
||||
await service.prepareAt(url, Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
expect(loader.spriteCalls, hasLength(6));
|
||||
});
|
||||
|
||||
testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async {
|
||||
var now = DateTime(2026, 10, 22);
|
||||
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
|
||||
|
||||
@@ -35,12 +35,12 @@ void main() {
|
||||
Key('player-play-toggle'),
|
||||
]) {
|
||||
expect(tester.getSize(find.byKey(key)), const Size(68, 68));
|
||||
// 圆形底改为半透明黑色,不再是完全透明或蓝色实底。
|
||||
// 圆形深色底:压在亮画面上也能看见播放/暂停。
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Container)),
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
expect(decoration.color, Colors.black26);
|
||||
expect(decoration.color, const Color(0x66000000));
|
||||
expect(decoration.shape, BoxShape.circle);
|
||||
final icon = tester.widget<Icon>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||
|
||||
@@ -983,9 +983,11 @@ void main() {
|
||||
expect(find.byKey(const Key('player-video-tap')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget);
|
||||
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
|
||||
// 清屏后左侧选集条一起收起,只留右下角的退出按钮。
|
||||
expect(find.byKey(const Key('player-episodes-pill')), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))),
|
||||
const Size(40, 40),
|
||||
const Size(40, 34),
|
||||
);
|
||||
|
||||
await tester.tapAt(const Offset(80, 300));
|
||||
@@ -1100,8 +1102,8 @@ void main() {
|
||||
expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing);
|
||||
expect(find.byKey(const Key('player-interaction-rail')), findsNothing);
|
||||
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
|
||||
// 清屏态本来显示的选集胶囊与右下按钮,在加速结束后恢复。
|
||||
expect(find.byKey(const Key('player-episodes-pill')), findsOneWidget);
|
||||
// 清屏态只恢复右下退出按钮;左侧选集条在清屏下一直不显示。
|
||||
expect(find.byKey(const Key('player-episodes-pill')), findsNothing);
|
||||
expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
@@ -1118,7 +1120,7 @@ void main() {
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-back'))),
|
||||
const Size(36, 36),
|
||||
const Size(40, 40),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
@@ -1160,17 +1162,12 @@ void main() {
|
||||
.child,
|
||||
isNotNull,
|
||||
);
|
||||
// 选集条给一层浅底色,标出可点区域。
|
||||
expect(
|
||||
tester
|
||||
.widget<Material>(find.byKey(const Key('player-episodes-pill')))
|
||||
.color,
|
||||
AppColors.playerOverlayPill,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Material>(find.byKey(const Key('player-episodes-pill')))
|
||||
.borderRadius,
|
||||
BorderRadius.circular(7),
|
||||
AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
@@ -1186,9 +1183,14 @@ void main() {
|
||||
?.fontSize,
|
||||
13.5,
|
||||
);
|
||||
// 清屏按钮与左侧选集条等高。
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))),
|
||||
const Size(40, 40),
|
||||
const Size(40, 34),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-episodes-pill'))).height,
|
||||
34,
|
||||
);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
|
||||
@@ -1289,7 +1291,8 @@ void main() {
|
||||
matching: find.byIcon(Icons.keyboard_arrow_up_rounded),
|
||||
),
|
||||
);
|
||||
expect(pill.right - arrow.right, closeTo(14, 0.5));
|
||||
// 选集条左右各留 10pt 内边距,箭头不再贴边。
|
||||
expect(pill.right - arrow.right, closeTo(10, 0.5));
|
||||
expect(arrow.left - label.right, closeTo(8, 0.5));
|
||||
});
|
||||
|
||||
@@ -1551,7 +1554,8 @@ void main() {
|
||||
),
|
||||
);
|
||||
expect(chevron.left - title.right, lessThanOrEqualTo(6));
|
||||
expect(chevron.right, lessThanOrEqualTo(320 - 14));
|
||||
// 剧名和简介一样收在互动栏左侧,不再铺到屏幕最右边。
|
||||
expect(chevron.right, lessThanOrEqualTo(320 - 14 - 54));
|
||||
final synopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('player-synopsis')),
|
||||
);
|
||||
@@ -1647,11 +1651,11 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('player-episodes')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-episodes-close')), findsOneWidget);
|
||||
expect(find.byKey(const Key('drama-info-sheet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('drama-info-close')), findsOneWidget);
|
||||
final grid = tester.widget<GridView>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('player-episodes-sheet')),
|
||||
of: find.byKey(const Key('drama-info-sheet')),
|
||||
matching: find.byType(GridView),
|
||||
),
|
||||
);
|
||||
@@ -1659,14 +1663,14 @@ void main() {
|
||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||
expect(delegate.crossAxisCount, 6);
|
||||
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
|
||||
find.byKey(const Key('player-episodes-system-overlay')),
|
||||
find.byKey(const Key('drama-info-system-overlay')),
|
||||
);
|
||||
expect(overlay.value.systemNavigationBarColor, AppColors.card);
|
||||
expect(overlay.value.systemNavigationBarContrastEnforced, isFalse);
|
||||
final safeArea = tester.widget<SafeArea>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('player-episodes-sheet')),
|
||||
of: find.byKey(const Key('drama-info-sheet')),
|
||||
matching: find.byType(SafeArea),
|
||||
)
|
||||
.first,
|
||||
@@ -2030,7 +2034,7 @@ void _expectFullscreenOverlay(WidgetTester tester) {
|
||||
expect(find.byKey(const Key('player-synopsis')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-more')), findsOneWidget);
|
||||
for (final key in const [Key('player-back'), Key('player-more')]) {
|
||||
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
|
||||
expect(tester.getSize(find.byKey(key)), const Size(40, 40));
|
||||
}
|
||||
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
|
||||
expect(find.byKey(const Key('player-episodes')), findsOneWidget);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -37,11 +37,8 @@ void main() {
|
||||
expect(find.byKey(const Key('scrub-overlay')), findsNothing);
|
||||
expect(harness.scrubStarts, 1);
|
||||
expect(_sliderTheme(tester).trackHeight, 6);
|
||||
expect(
|
||||
(_sliderTheme(tester).thumbShape! as RoundSliderThumbShape)
|
||||
.enabledThumbRadius,
|
||||
8,
|
||||
);
|
||||
// 不画滑块圆点,进度只靠已播段表达。
|
||||
expect(_sliderTheme(tester).thumbShape, SliderComponentShape.noThumb);
|
||||
|
||||
await gesture.moveBy(const Offset(72, 0));
|
||||
await tester.pump();
|
||||
@@ -67,11 +64,7 @@ void main() {
|
||||
expect(harness.scrubEnds, 1);
|
||||
expect(harness.seeks, 1);
|
||||
expect(_sliderTheme(tester).trackHeight, 2);
|
||||
expect(
|
||||
(_sliderTheme(tester).thumbShape! as RoundSliderThumbShape)
|
||||
.enabledThumbRadius,
|
||||
6,
|
||||
);
|
||||
expect(_sliderTheme(tester).thumbShape, SliderComponentShape.noThumb);
|
||||
});
|
||||
|
||||
testWidgets('feed progress tap does not clear overlay content', (
|
||||
@@ -154,29 +147,27 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('feed progress thumb stays on the finger when dragged right', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpFeedOverlay(tester);
|
||||
final bar = find.byKey(const Key('home-feed-progress'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(bar));
|
||||
testWidgets(
|
||||
'feed progress played segment follows the finger when dragged right',
|
||||
(tester) async {
|
||||
await _pumpFeedOverlay(tester);
|
||||
final bar = find.byKey(const Key('home-feed-progress'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(bar));
|
||||
|
||||
await gesture.moveBy(const Offset(800, 0));
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(800, 0));
|
||||
await tester.pump();
|
||||
|
||||
final thumbCenter = tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||
.center
|
||||
.dx;
|
||||
final playedRight = tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-played')))
|
||||
.right;
|
||||
expect(thumbCenter, closeTo(tester.getRect(bar).right, 0.5));
|
||||
expect(playedRight, closeTo(thumbCenter, 0.5));
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-played')))
|
||||
.right,
|
||||
closeTo(tester.getRect(bar).right, 0.5),
|
||||
);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('full player drag clears chrome and release restores it', (
|
||||
tester,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/recommend/widgets/home_hero_carousel.dart';
|
||||
|
||||
const List<FeedBanner> _banners = [
|
||||
FeedBanner(id: '1', imageUrl: 'https://cdn.test/a.png'),
|
||||
FeedBanner(id: '2', imageUrl: 'https://cdn.test/b.png'),
|
||||
];
|
||||
|
||||
Widget _harness(List<FeedBanner> banners) {
|
||||
return MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Scaffold(
|
||||
body: HomeHeroCarousel(
|
||||
banners: banners,
|
||||
dramaFor: (_) => null,
|
||||
onWatch: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color? _dotColor(WidgetTester tester, int index) {
|
||||
final decoration =
|
||||
tester
|
||||
.widget<Container>(find.byKey(Key('home-hero-dot-$index')))
|
||||
.decoration
|
||||
as BoxDecoration?;
|
||||
return decoration?.color;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('should_keep_looping_backwards_from_the_first_banner', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_harness(_banners));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_dotColor(tester, 0), AppColors.brandRed);
|
||||
|
||||
// 从第一张往右滑:无缝轮播应该接到最后一张,而不是被边界挡住。
|
||||
await tester.fling(find.byType(PageView), const Offset(700, 0), 1200);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_dotColor(tester, 1), AppColors.brandRed);
|
||||
expect(_dotColor(tester, 0), isNot(AppColors.brandRed));
|
||||
});
|
||||
|
||||
testWidgets('should_wrap_to_first_banner_after_last_auto_advance', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_harness(_banners));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 自动播两次:第 2 张之后应回到第 1 张,且页码继续往前走不回头。
|
||||
await tester.pump(const Duration(milliseconds: 4600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_dotColor(tester, 1), AppColors.brandRed);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 4600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_dotColor(tester, 0), AppColors.brandRed);
|
||||
});
|
||||
|
||||
testWidgets('should_not_loop_when_only_one_banner', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_harness(const [FeedBanner(id: '1', imageUrl: 'https://cdn.test/a.png')]),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('home-hero-dot-0')), findsNothing);
|
||||
await tester.pump(const Duration(milliseconds: 6000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
import 'package:fruitshorts/pages/recommend/recommend_discover_page.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
@@ -34,7 +36,10 @@ void main() {
|
||||
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('recommend-top-gradient')), findsOneWidget);
|
||||
final recommendText = tester.widget<Text>(
|
||||
find.descendant(
|
||||
@@ -65,7 +70,9 @@ void main() {
|
||||
find.byKey(const Key('recommend-top-nav')),
|
||||
);
|
||||
// 右侧依次是搜索和金色礼物盒(钱包入口),礼物盒贴右边距 8。
|
||||
final searchRect = tester.getRect(find.byKey(const Key('recommend-search')));
|
||||
final searchRect = tester.getRect(
|
||||
find.byKey(const Key('recommend-search')),
|
||||
);
|
||||
final giftRect = tester.getRect(find.byKey(const Key('recommend-gift')));
|
||||
expect(giftRect.right, closeTo(topNavRect.right - 8, 0.1));
|
||||
expect(searchRect.right, lessThanOrEqualTo(giftRect.left));
|
||||
@@ -78,7 +85,12 @@ void main() {
|
||||
tester
|
||||
.getRect(find.byKey(const Key('recommend-top-nav')))
|
||||
.overlaps(
|
||||
tester.getRect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false)),
|
||||
tester.getRect(
|
||||
find.byKey(
|
||||
const Key('recommend-feed-player'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
@@ -387,7 +399,10 @@ void main() {
|
||||
tester.getRect(find.byKey(const Key('all-dramas-filters'))).top,
|
||||
closeTo(fixedFilterTop, 0.1),
|
||||
);
|
||||
expect(find.byKey(const Key('all-filter-classification-all')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('all-filter-classification-all')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('all-filter-category-all')), findsOneWidget);
|
||||
expect(find.byKey(const Key('all-filter-tag-all')), findsOneWidget);
|
||||
expect(
|
||||
@@ -407,7 +422,10 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all')));
|
||||
@@ -430,7 +448,10 @@ void main() {
|
||||
await tester.drag(pages, const Offset(-320, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
@@ -454,7 +475,10 @@ void main() {
|
||||
|
||||
await tester.drag(pages, const Offset(640, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
||||
_drainOverflows(tester);
|
||||
});
|
||||
@@ -497,7 +521,9 @@ void main() {
|
||||
await _pumpRecommend(tester);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('recommend-feed-player'), skipOffstage: false)),
|
||||
tester.getCenter(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
),
|
||||
);
|
||||
await gesture.moveBy(const Offset(0, 80));
|
||||
await tester.pump();
|
||||
@@ -505,7 +531,10 @@ void main() {
|
||||
final feedScroll = tester.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
of: find.byKey(
|
||||
const Key('recommend-feed-player'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
@@ -544,6 +573,25 @@ void main() {
|
||||
expect(search.focusNode?.hasFocus, isTrue);
|
||||
_drainOverflows(tester);
|
||||
});
|
||||
|
||||
test('should_judge_new_arrival_only_by_tag_code', () {
|
||||
Drama tagged({String tagCode = '', String tag = ''}) => Drama(
|
||||
id: 'tagged',
|
||||
title: 'Tagged',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
tag: tag,
|
||||
tagCode: tagCode,
|
||||
);
|
||||
|
||||
expect(isNewArrivalTag(tagged(tagCode: 'NEW_ARRIVAL')), isTrue);
|
||||
expect(isNewArrivalTag(tagged(tagCode: ' new_arrival ')), isTrue);
|
||||
// 其它语义码(以及没有码的标签)都不算上新,文案不参与判断。
|
||||
expect(isNewArrivalTag(tagged(tagCode: 'HOT')), isFalse);
|
||||
expect(isNewArrivalTag(tagged(tagCode: 'NEW_RELEASE')), isFalse);
|
||||
expect(isNewArrivalTag(tagged(tag: '上新')), isFalse);
|
||||
expect(isNewArrivalTag(tagged()), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
/// 测试字体(等宽方块字形)比真实字体宽/高,卡片固定高度会报 2~4px
|
||||
|
||||
Reference in new issue
Block a user