6 Commits
Author SHA1 Message Date
Vicky 3590cd0c55 feat(recommend): 上新板块改按后端语义码判断,顶部轮播改为无缝循环
flutter-ci / test (push) Failing after 30m39s
Drama 新增 tagCode,推荐页只看 NEW_ARRIVAL 这个码决定上新横滑,
不再依赖可变的标签文案;分类网格高度改为复用卡片的统一常量。
头部轮播改用虚拟无限页码,首尾相接且能反向滑,自动播放不再倒滑回第一张。
2026-10-29 15:44:33 +08:00
Vicky c311985601 fix(ui): 列表剧名统一两行省略,收藏卡去掉观看进度副标题
筛选、追剧收藏、新片、榜单、缓存与推荐上新等列表卡都按两行剧名预留高度,
超长标题显示省略号;收藏卡不再显示「看到第 N 集」,文字区高度同步收窄。
2026-10-29 15:44:30 +08:00
Vicky 4dd37b2588 fix(i18n): 缩短我的页收藏、缓存与历史的英文和印尼语文案
My Favorites / Offline downloads / Watch History 在菜单里过长,
统一收成 Favorites / Downloads / History 与 Favorit / Unduhan / Riwayat。
2026-10-29 15:44:26 +08:00
Vicky 543d0aadd5 style(player): 播放页与首页浮层的控件样式统一
播放页顶部按钮与选集条去掉底色、进度条不再画滑块圆点并压到最底部;
剧名与简介收在互动栏左侧,清屏后只留右下角退出按钮。
首页观看条与进度条共用 20pt 页边距,进度槽底色与播放页一致。
2026-10-29 15:44:22 +08:00
Vicky d5bcda54d3 fix(player): 雪碧图缓存补齐槽位预取量,清屏按钮贴近进度条
三个渲染槽位各预取 2 张雪碧图,容量 3 装不下会导致「重建→重新下载解码→
notify→重建」的死循环,真机持续发烫掉帧;容量恢复为 8 并只在未命中时预取。
首页清屏态的退出按钮由距底 56 收到 24,贴着进度条上方。
2026-10-29 15:44:17 +08:00
Vicky 79463e65c2 feat(player): 剧名与选集合并为同一个弹窗
点剧名默认停在详情页签,选集入口直接停在选集页签,不再跳详情页;
详情页签补上简介、标签、收藏与相似推荐,原选集弹窗合并进来后删除。
2026-10-29 15:44:10 +08:00
39 changed files with 1233 additions and 620 deletions

No files matched your search

+18 -3
View File
@@ -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 : '';
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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}",
+9 -3
View File
@@ -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.
+6 -3
View File
@@ -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';
+6 -3
View File
@@ -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';
+3
View File
@@ -638,6 +638,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get episodes => '选集';
@override
String get similarDramas => '相似推荐';
@override
String get synopsisTitle => '简介';
+1
View File
@@ -281,6 +281,7 @@
"episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集",
"episodes": "选集",
"similarDramas": "相似推荐",
"synopsisTitle": "简介",
"watchEpisodeOne": "观看第 1 集",
"continueEpisode": "续看第 {number} 集",
+1 -1
View File
@@ -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,
+3 -1
View File
@@ -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),
);
}
+1 -18
View File
@@ -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,
+20 -25
View File
@@ -35,12 +35,11 @@ import '../../core/theme/app_colors.dart';
import '../../core/navigation/fade_page_route.dart';
import '../../l10n/app_localizations.dart';
import '../discover/search_page.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_types.dart';
import '../mine/mine_view.dart';
import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
import 'widgets/episode_picker_sheet.dart';
import 'widgets/drama_info_sheet.dart';
typedef _PlaybackSnapshot = ({
Duration position,
@@ -1294,10 +1293,7 @@ class PlayerLogic extends ChangeNotifier {
}
// 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
await Future.wait([
pendingWrite,
if (!resumeLoaded) loadResumePoint(),
]);
await Future.wait([pendingWrite, if (!resumeLoaded) loadResumePoint()]);
if (resumePositionFor(episode.id) == null) {
final dramaId = _dramaIdForEpisode(episode.id);
final cached = dramaId == null
@@ -1836,22 +1832,9 @@ class PlayerLogic extends ChangeNotifier {
if (context.mounted) Navigator.of(context).pop();
}
Future<void> openDetail(BuildContext context) async {
session.setOverlayOpen(true);
await saveCurrentProgress();
try {
if (!context.mounted) return;
await Navigator.of(context).push(
FadePageRoute<void>(
builder: (_) =>
DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama),
),
);
} finally {
session.setOverlayOpen(false);
}
await saveCurrentProgress();
}
/// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。
Future<void> openDetail(BuildContext context) =>
_showDramaSheet(context, DramaInfoTab.detail);
String? _dramaIdForEpisode(String episodeId) {
for (final entry in queue.entries) {
@@ -1887,13 +1870,21 @@ class PlayerLogic extends ChangeNotifier {
await resumeAfterFullPlayer();
}
Future<void> showEpisodes(BuildContext context) async {
/// 选集入口:同一个弹窗,直接停在选集页签。
Future<void> showEpisodes(BuildContext context) =>
_showDramaSheet(context, DramaInfoTab.episodes);
Future<void> _showDramaSheet(
BuildContext context,
DramaInfoTab initialTab,
) async {
final entry = queue.entryAt(session.currentIndex);
final drama = await _withFullEpisodes(context, entry.drama);
if (!context.mounted) return;
final homeFeed = feedDramas != null;
final currentNumber = entry.episode.number;
session.setOverlayOpen(true);
await saveCurrentProgress();
await session.playbackSettled;
if (!context.mounted) {
session.setOverlayOpen(false);
@@ -1904,16 +1895,20 @@ class PlayerLogic extends ChangeNotifier {
context: context,
backgroundColor: AppColors.transparent,
isScrollControlled: true,
builder: (_) => EpisodePickerSheet(
builder: (_) => DramaInfoSheet(
drama: drama,
currentNumber: currentNumber,
onSelect: (index) =>
initialTab: initialTab,
onSelectEpisode: (index) =>
_selectFromSheet(context, drama, index, homeFeed, currentNumber),
onOpenRelated: (related) =>
unawaited(openSeriesAt(context, related, 0)),
),
);
} finally {
session.setOverlayOpen(false);
}
await saveCurrentProgress();
}
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
+13 -11
View File
@@ -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,
),
),
);
}
+30 -27
View File
@@ -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),
];
+82 -59
View File
@@ -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,
),
+1 -1
View File
@@ -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),
],
),
),
),
+34 -59
View File
@@ -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);
+2 -2
View File
@@ -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)),
+24 -20
View File
@@ -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);
+5 -2
View File
@@ -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);
+21 -30
View File
@@ -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);
});
}
+57 -9
View File
@@ -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