From de7da648863d79dd3ee1dafd8d8b5c75218f28d7 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Wed, 28 Oct 2026 16:36:43 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E9=87=8D=E8=AE=BE=E8=AE=A1=E5=B9=B6=E7=BB=9F=E4=B8=80=E5=BC=B9?= =?UTF-8?q?=E6=A1=86=E4=B8=BB=E9=A2=98=E6=B8=90=E5=8F=98=E5=A4=96=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/app_glow_panel.dart | 119 ++++++++++++++ lib/core/widgets/auth_gate.dart | 12 +- lib/core/widgets/catalog_endpoint_editor.dart | 7 +- lib/l10n/app_en.arb | 53 +++++- lib/l10n/app_id.arb | 53 +++++- lib/l10n/app_localizations.dart | 116 +++++++++++-- lib/l10n/app_localizations_en.dart | 73 ++++++++- lib/l10n/app_localizations_id.dart | 75 +++++++-- lib/l10n/app_localizations_zh.dart | 72 ++++++++- lib/l10n/app_zh.arb | 55 ++++++- .../account/account_deactivate_page.dart | 9 +- lib/pages/auth/auth_form_widgets.dart | 92 +++++------ lib/pages/auth/auth_legal_links.dart | 2 +- lib/pages/auth/auth_view.dart | 152 ++++++++++-------- lib/pages/auth/auth_widgets.dart | 129 ++++++--------- lib/pages/auth/phone_auth_sheet.dart | 22 +-- lib/pages/downloads/downloads_page.dart | 11 +- .../interaction/widgets/report_dialog.dart | 7 +- lib/pages/notices/notices_page.dart | 7 +- .../player/widgets/drama_comments_sheet.dart | 18 ++- .../player/widgets/episode_picker_sheet.dart | 20 +-- .../player/widgets/player_more_sheet.dart | 86 +++++++++- .../player/widgets/player_speed_sheet.dart | 14 +- .../player/widgets/player_unlock_sheet.dart | 9 +- .../watch_records/watch_records_page.dart | 9 +- 25 files changed, 905 insertions(+), 317 deletions(-) create mode 100644 lib/core/widgets/app_glow_panel.dart diff --git a/lib/core/widgets/app_glow_panel.dart b/lib/core/widgets/app_glow_panel.dart new file mode 100644 index 0000000..e92533d --- /dev/null +++ b/lib/core/widgets/app_glow_panel.dart @@ -0,0 +1,119 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +/// 主题红晕渐变容器:弹框(居中)与底部弹层(半屏)共用的外壳。 +/// 纯黑弹框统一升级为「顶部红光 → 近黑」的家族渐变。 +class AppGlowPanel extends StatelessWidget { + const AppGlowPanel({ + super.key, + required this.child, + this.borderRadius = const BorderRadius.all(Radius.circular(20)), + this.border = true, + }); + + final Widget child; + final BorderRadius borderRadius; + final bool border; + + static const _gradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + Color(0xFF101012), + ], + stops: [0, 0.22, 0.62], + ); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + gradient: _gradient, + borderRadius: borderRadius, + border: border + ? Border.all(color: AppColors.contentBorder) + : null, + ), + child: child, + ); + } +} + +/// 主题渐变确认弹框:替代 AlertDialog 的家族化外壳。 +/// 按钮沿用项目惯例:纯文字 TextButton,主操作品牌红。 +class AppGlowDialog extends StatelessWidget { + const AppGlowDialog({ + super.key, + this.title, + this.content, + this.contentChild, + this.actions, + this.contentTextStyle, + }); + + final String? title; + final String? content; + + /// 富内容(表单/滚动区),与 [content] 二选一。 + final Widget? contentChild; + final List? actions; + final TextStyle? contentTextStyle; + + @override + Widget build(BuildContext context) { + final bodyStyle = contentTextStyle ?? + TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + fontWeight: FontWeight.w400, + ); + return Dialog( + backgroundColor: AppColors.transparent, + elevation: 0, + child: AppGlowPanel( + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 22, 22, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Text( + title!, + style: TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 14), + ], + if (content != null) + Text(content!, style: bodyStyle) + else + ?contentChild, + if (actions != null && actions!.isNotEmpty) ...[ + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + for (var i = 0; i < actions!.length; i++) ...[ + if (i > 0) const SizedBox(width: 4), + actions![i], + ], + ], + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/auth_gate.dart b/lib/core/widgets/auth_gate.dart index b238dfc..9f85fc6 100644 --- a/lib/core/widgets/auth_gate.dart +++ b/lib/core/widgets/auth_gate.dart @@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart'; import '../../pages/auth/auth_view.dart'; import '../data/services/auth_controller.dart'; import '../theme/app_colors.dart'; +import 'app_glow_panel.dart'; /// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库) Future openAuthPage(BuildContext context, {bool signUpMode = false}) { @@ -23,11 +24,11 @@ Future ensureSignedIn(BuildContext context) async { final l10n = AppLocalizations.of(context)!; final go = await showModalBottomSheet( context: context, - backgroundColor: AppColors.card, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(18)), - ), - builder: (sheetContext) => SafeArea( + backgroundColor: AppColors.transparent, + builder: (sheetContext) => AppGlowPanel( + borderRadius: const BorderRadius.vertical(top: Radius.circular(18)), + border: false, + child: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), child: Column( @@ -71,6 +72,7 @@ Future ensureSignedIn(BuildContext context) async { ], ), ), + ), ), ); diff --git a/lib/core/widgets/catalog_endpoint_editor.dart b/lib/core/widgets/catalog_endpoint_editor.dart index 1016672..cafd56c 100644 --- a/lib/core/widgets/catalog_endpoint_editor.dart +++ b/lib/core/widgets/catalog_endpoint_editor.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../config/server_endpoint.dart'; import '../theme/app_colors.dart'; +import 'app_glow_panel.dart'; const String _title = '设置服务地址'; const String _save = '保存'; @@ -72,9 +73,9 @@ class _CatalogEndpointDialogState extends State<_CatalogEndpointDialog> { @override Widget build(BuildContext context) { - return AlertDialog( - title: const Text(_title), - content: SingleChildScrollView( + return AppGlowDialog( + title: _title, + contentChild: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0536a3e..4d11cd9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -20,7 +20,6 @@ "featured": "Featured", "recommended": "Recommended", "fruitShortsBrand": "FruitShorts", - "authLandingSubtitle": "Sign in to sync progress, favorites and coins", "comingSoon": "Coming soon", "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", "authLegalLead": "By continuing, you agree to the", @@ -61,7 +60,7 @@ "episodes": {"type": "String"} } }, - "feedEpisodePrefix": "EP{episode} | ", + "feedEpisodePrefix": "EP{episode}", "@feedEpisodePrefix": { "placeholders": { "episode": {"type": "int"} @@ -275,6 +274,7 @@ "shareFailed": "Could not open the share sheet.", "nowPlaying": "Playing now", "pictureInPicture": "Picture in picture", + "autoEnterPip": "Auto mini player on exit", "pipUnavailable": "Picture in picture is unavailable on this device", "originalTag": "Original", "originalProduction": "Original", @@ -442,6 +442,7 @@ "restoreDefault": "Restore default", "invalidServerAddress": "Enter a valid http(s) server address.", "signOut": "Sign Out", + "signOutConfirm": "Sign out of your current account?", "mockUserName": "Vicky sheng", "mockUserId": "ID: M1CZWVYE", "mockNotice": "Sample data only.", @@ -787,8 +788,14 @@ "offlineDownloads": "Offline downloads", "watchPreferences": "Watch preferences", "contactSupport": "Contact support", - "vipBrandName": "Netshort VIP", - "mineVipSubtitle": "Enjoy exclusive benefits", + "vipBrandName": "FruitShorts VIP", + "vipOpenNow": "Get VIP", + "vipBenefitFree": "Free full access", + "vipBenefitNoAds": "Ad-free viewing", + "vipBenefitHd": "1080P HD", + "vipBenefitFastCache": "Fast downloads", + "moreServices": "More", + "mineVipSubtitle": "Unlock excitement, binge freely", "favoriteEpisodeProgress": "Episode {episode} / {total} total", "@favoriteEpisodeProgress": { "placeholders": { @@ -796,6 +803,44 @@ "total": { "type": "int" } } }, + "myFavorites": "My Favorites", + "manage": "Manage", + "continueWatching": "Continue Watching", + "continueWatchingSubtitle": "Episode {episode} of {total}", + "@continueWatchingSubtitle": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "favoriteDramas": "Favorites", + "totalDramasCount": "{count} titles", + "@totalDramasCount": { + "placeholders": { + "count": { "type": "int" } + } + }, + "watchedEpisodeTotal": "EP {episode} / {total}", + "@watchedEpisodeTotal": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "hasUpdateBadge": "UPDATED", + "updatedToEpisode": "Updated to EP {episode}", + "@updatedToEpisode": { + "placeholders": { + "episode": { "type": "int" } + } + }, + "finishedAllEpisodes": "{total} eps · Completed", + "@finishedAllEpisodes": { + "placeholders": { + "total": { "type": "int" } + } + }, + "noFavoritesYet": "No favorites yet", "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Watch all", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c6be6ec..7380926 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -20,7 +20,6 @@ "featured": "Unggulan", "recommended": "Rekomendasi", "fruitShortsBrand": "FruitShorts", - "authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin", "comingSoon": "Segera hadir", "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", "authLegalLead": "Dengan melanjutkan, Anda menyetujui", @@ -55,7 +54,7 @@ "episodes": {"type": "String"} } }, - "feedEpisodePrefix": "EP{episode} | ", + "feedEpisodePrefix": "EP{episode}", "@feedEpisodePrefix": { "placeholders": { "episode": {"type": "int"} @@ -254,6 +253,7 @@ "shareFailed": "Tidak bisa membuka menu berbagi.", "nowPlaying": "Sedang diputar", "pictureInPicture": "Gambar dalam gambar", + "autoEnterPip": "Mini player otomatis saat keluar", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "originalTag": "Original", "originalProduction": "Original", @@ -421,6 +421,7 @@ "restoreDefault": "Pulihkan default", "invalidServerAddress": "Masukkan alamat server http(s) yang valid.", "signOut": "Keluar", + "signOutConfirm": "Keluar dari akun saat ini?", "mockUserName": "Vicky sheng", "mockUserId": "ID: M1CZWVYE", "mockNotice": "Hanya data contoh.", @@ -749,8 +750,14 @@ "offlineDownloads": "Unduhan offline", "watchPreferences": "Preferensi tontonan", "contactSupport": "Hubungi dukungan", - "vipBrandName": "Netshort VIP", - "mineVipSubtitle": "Nikmati manfaat eksklusif", + "vipBrandName": "FruitShorts VIP", + "vipOpenNow": "Buka VIP", + "vipBenefitFree": "Nonton semua gratis", + "vipBenefitNoAds": "Bebas iklan", + "vipBenefitHd": "HD 1080P", + "vipBenefitFastCache": "Unduhan cepat", + "moreServices": "Lainnya", + "mineVipSubtitle": "Buka keseruan, tonton sepuasnya", "favoriteEpisodeProgress": "Episode {episode} / total {total}", "@favoriteEpisodeProgress": { "placeholders": { @@ -758,6 +765,44 @@ "total": { "type": "int" } } }, + "myFavorites": "Favorit Saya", + "manage": "Kelola", + "continueWatching": "Lanjutkan", + "continueWatchingSubtitle": "Episode {episode} dari {total}", + "@continueWatchingSubtitle": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "favoriteDramas": "Koleksi", + "totalDramasCount": "{count} judul", + "@totalDramasCount": { + "placeholders": { + "count": { "type": "int" } + } + }, + "watchedEpisodeTotal": "EP {episode} / {total}", + "@watchedEpisodeTotal": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "hasUpdateBadge": "UPDATE", + "updatedToEpisode": "Diperbarui ke EP {episode}", + "@updatedToEpisode": { + "placeholders": { + "episode": { "type": "int" } + } + }, + "finishedAllEpisodes": "{total} eps · Selesai", + "@finishedAllEpisodes": { + "placeholders": { + "total": { "type": "int" } + } + }, + "noFavoritesYet": "Belum ada favorit", "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Tonton semua", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0975ce6..25752dc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -220,12 +220,6 @@ abstract class AppLocalizations { /// **'FruitShorts'** String get fruitShortsBrand; - /// No description provided for @authLandingSubtitle. - /// - /// In en, this message translates to: - /// **'Sign in to sync progress, favorites and coins'** - String get authLandingSubtitle; - /// No description provided for @comingSoon. /// /// In en, this message translates to: @@ -403,7 +397,7 @@ abstract class AppLocalizations { /// No description provided for @feedEpisodePrefix. /// /// In en, this message translates to: - /// **'EP{episode} | '** + /// **'EP{episode}'** String feedEpisodePrefix(int episode); /// No description provided for @feedWatchingCount. @@ -1198,6 +1192,12 @@ abstract class AppLocalizations { /// **'Picture in picture'** String get pictureInPicture; + /// No description provided for @autoEnterPip. + /// + /// In en, this message translates to: + /// **'Auto mini player on exit'** + String get autoEnterPip; + /// No description provided for @pipUnavailable. /// /// In en, this message translates to: @@ -1828,6 +1828,12 @@ abstract class AppLocalizations { /// **'Sign Out'** String get signOut; + /// No description provided for @signOutConfirm. + /// + /// In en, this message translates to: + /// **'Sign out of your current account?'** + String get signOutConfirm; + /// No description provided for @mockUserName. /// /// In en, this message translates to: @@ -2125,7 +2131,7 @@ abstract class AppLocalizations { /// No description provided for @myFavorites. /// /// In en, this message translates to: - /// **'My favorites'** + /// **'My Favorites'** String get myFavorites; /// No description provided for @noFavorites. @@ -3427,13 +3433,49 @@ abstract class AppLocalizations { /// No description provided for @vipBrandName. /// /// In en, this message translates to: - /// **'Netshort VIP'** + /// **'FruitShorts VIP'** String get vipBrandName; + /// No description provided for @vipOpenNow. + /// + /// In en, this message translates to: + /// **'Get VIP'** + String get vipOpenNow; + + /// No description provided for @vipBenefitFree. + /// + /// In en, this message translates to: + /// **'Free full access'** + String get vipBenefitFree; + + /// No description provided for @vipBenefitNoAds. + /// + /// In en, this message translates to: + /// **'Ad-free viewing'** + String get vipBenefitNoAds; + + /// No description provided for @vipBenefitHd. + /// + /// In en, this message translates to: + /// **'1080P HD'** + String get vipBenefitHd; + + /// No description provided for @vipBenefitFastCache. + /// + /// In en, this message translates to: + /// **'Fast downloads'** + String get vipBenefitFastCache; + + /// No description provided for @moreServices. + /// + /// In en, this message translates to: + /// **'More'** + String get moreServices; + /// No description provided for @mineVipSubtitle. /// /// In en, this message translates to: - /// **'Enjoy exclusive benefits'** + /// **'Unlock excitement, binge freely'** String get mineVipSubtitle; /// No description provided for @favoriteEpisodeProgress. @@ -3442,6 +3484,60 @@ abstract class AppLocalizations { /// **'Episode {episode} / {total} total'** String favoriteEpisodeProgress(int episode, int total); + /// No description provided for @manage. + /// + /// In en, this message translates to: + /// **'Manage'** + String get manage; + + /// No description provided for @continueWatchingSubtitle. + /// + /// In en, this message translates to: + /// **'Episode {episode} of {total}'** + String continueWatchingSubtitle(int episode, int total); + + /// No description provided for @favoriteDramas. + /// + /// In en, this message translates to: + /// **'Favorites'** + String get favoriteDramas; + + /// No description provided for @totalDramasCount. + /// + /// In en, this message translates to: + /// **'{count} titles'** + String totalDramasCount(int count); + + /// No description provided for @watchedEpisodeTotal. + /// + /// In en, this message translates to: + /// **'EP {episode} / {total}'** + String watchedEpisodeTotal(int episode, int total); + + /// No description provided for @hasUpdateBadge. + /// + /// In en, this message translates to: + /// **'UPDATED'** + String get hasUpdateBadge; + + /// No description provided for @updatedToEpisode. + /// + /// In en, this message translates to: + /// **'Updated to EP {episode}'** + String updatedToEpisode(int episode); + + /// No description provided for @finishedAllEpisodes. + /// + /// In en, this message translates to: + /// **'{total} eps · Completed'** + String finishedAllEpisodes(int total); + + /// No description provided for @noFavoritesYet. + /// + /// In en, this message translates to: + /// **'No favorites yet'** + String get noFavoritesYet; + /// No description provided for @watchedToEpisode. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2ba335d..9a769fb 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -69,10 +69,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fruitShortsBrand => 'FruitShorts'; - @override - String get authLandingSubtitle => - 'Sign in to sync progress, favorites and coins'; - @override String get comingSoon => 'Coming soon'; @@ -167,7 +163,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String feedEpisodePrefix(int episode) { - return 'EP$episode | '; + return 'EP$episode'; } @override @@ -597,6 +593,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pictureInPicture => 'Picture in picture'; + @override + String get autoEnterPip => 'Auto mini player on exit'; + @override String get pipUnavailable => 'Picture in picture is unavailable on this device'; @@ -933,6 +932,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get signOut => 'Sign Out'; + @override + String get signOutConfirm => 'Sign out of your current account?'; + @override String get mockUserName => 'Vicky sheng'; @@ -1087,7 +1089,7 @@ class AppLocalizationsEn extends AppLocalizations { 'Could not update favorites. Please retry.'; @override - String get myFavorites => 'My favorites'; + String get myFavorites => 'My Favorites'; @override String get noFavorites => 'No favorites yet.'; @@ -1790,16 +1792,71 @@ class AppLocalizationsEn extends AppLocalizations { String get contactSupport => 'Contact support'; @override - String get vipBrandName => 'Netshort VIP'; + String get vipBrandName => 'FruitShorts VIP'; @override - String get mineVipSubtitle => 'Enjoy exclusive benefits'; + String get vipOpenNow => 'Get VIP'; + + @override + String get vipBenefitFree => 'Free full access'; + + @override + String get vipBenefitNoAds => 'Ad-free viewing'; + + @override + String get vipBenefitHd => '1080P HD'; + + @override + String get vipBenefitFastCache => 'Fast downloads'; + + @override + String get moreServices => 'More'; + + @override + String get mineVipSubtitle => 'Unlock excitement, binge freely'; @override String favoriteEpisodeProgress(int episode, int total) { return 'Episode $episode / $total total'; } + @override + String get manage => 'Manage'; + + @override + String continueWatchingSubtitle(int episode, int total) { + return 'Episode $episode of $total'; + } + + @override + String get favoriteDramas => 'Favorites'; + + @override + String totalDramasCount(int count) { + return '$count titles'; + } + + @override + String watchedEpisodeTotal(int episode, int total) { + return 'EP $episode / $total'; + } + + @override + String get hasUpdateBadge => 'UPDATED'; + + @override + String updatedToEpisode(int episode) { + return 'Updated to EP $episode'; + } + + @override + String finishedAllEpisodes(int total) { + return '$total eps · Completed'; + } + + @override + String get noFavoritesYet => 'No favorites yet'; + @override String watchedToEpisode(int episode) { return 'Watched to Episode $episode'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4e9d1dd..2172eeb 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -69,10 +69,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get fruitShortsBrand => 'FruitShorts'; - @override - String get authLandingSubtitle => - 'Masuk untuk menyinkronkan progres, favorit, dan koin'; - @override String get comingSoon => 'Segera hadir'; @@ -167,7 +163,7 @@ class AppLocalizationsId extends AppLocalizations { @override String feedEpisodePrefix(int episode) { - return 'EP$episode | '; + return 'EP$episode'; } @override @@ -299,7 +295,7 @@ class AppLocalizationsId extends AppLocalizations { String get localDemoProfile => 'Profil demo lokal'; @override - String get continueWatching => 'Lanjut Tonton'; + String get continueWatching => 'Lanjutkan'; @override String episodeRemaining(int episode, int minutes) { @@ -598,6 +594,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get pictureInPicture => 'Gambar dalam gambar'; + @override + String get autoEnterPip => 'Mini player otomatis saat keluar'; + @override String get pipUnavailable => 'Gambar dalam gambar tidak tersedia di perangkat ini'; @@ -937,6 +936,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get signOut => 'Keluar'; + @override + String get signOutConfirm => 'Keluar dari akun saat ini?'; + @override String get mockUserName => 'Vicky sheng'; @@ -1089,7 +1091,7 @@ class AppLocalizationsId extends AppLocalizations { String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.'; @override - String get myFavorites => 'Favorit saya'; + String get myFavorites => 'Favorit Saya'; @override String get noFavorites => 'Belum ada favorit.'; @@ -1792,16 +1794,71 @@ class AppLocalizationsId extends AppLocalizations { String get contactSupport => 'Hubungi dukungan'; @override - String get vipBrandName => 'Netshort VIP'; + String get vipBrandName => 'FruitShorts VIP'; @override - String get mineVipSubtitle => 'Nikmati manfaat eksklusif'; + String get vipOpenNow => 'Buka VIP'; + + @override + String get vipBenefitFree => 'Nonton semua gratis'; + + @override + String get vipBenefitNoAds => 'Bebas iklan'; + + @override + String get vipBenefitHd => 'HD 1080P'; + + @override + String get vipBenefitFastCache => 'Unduhan cepat'; + + @override + String get moreServices => 'Lainnya'; + + @override + String get mineVipSubtitle => 'Buka keseruan, tonton sepuasnya'; @override String favoriteEpisodeProgress(int episode, int total) { return 'Episode $episode / total $total'; } + @override + String get manage => 'Kelola'; + + @override + String continueWatchingSubtitle(int episode, int total) { + return 'Episode $episode dari $total'; + } + + @override + String get favoriteDramas => 'Koleksi'; + + @override + String totalDramasCount(int count) { + return '$count judul'; + } + + @override + String watchedEpisodeTotal(int episode, int total) { + return 'EP $episode / $total'; + } + + @override + String get hasUpdateBadge => 'UPDATE'; + + @override + String updatedToEpisode(int episode) { + return 'Diperbarui ke EP $episode'; + } + + @override + String finishedAllEpisodes(int total) { + return '$total eps · Selesai'; + } + + @override + String get noFavoritesYet => 'Belum ada favorit'; + @override String watchedToEpisode(int episode) { return 'Ditonton hingga Episode $episode'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index dbf6af9..9bce81c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -66,10 +66,7 @@ class AppLocalizationsZh extends AppLocalizations { String get recommended => '推荐'; @override - String get fruitShortsBrand => '水果短剧'; - - @override - String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; + String get fruitShortsBrand => 'FruitShorts'; @override String get comingSoon => '即将上线'; @@ -164,7 +161,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String feedEpisodePrefix(int episode) { - return '第$episode集|'; + return '第$episode集'; } @override @@ -591,6 +588,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pictureInPicture => '画中画'; + @override + String get autoEnterPip => '退出后自动小窗'; + @override String get pipUnavailable => '此设备无法使用画中画'; @@ -922,6 +922,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get signOut => '退出登录'; + @override + String get signOutConfirm => '确定要退出当前账号吗?'; + @override String get mockUserName => 'Vicky sheng'; @@ -1764,16 +1767,71 @@ class AppLocalizationsZh extends AppLocalizations { String get contactSupport => '联系客服'; @override - String get vipBrandName => 'Netshort VIP'; + String get vipBrandName => 'FruitShorts VIP'; @override - String get mineVipSubtitle => '享受专属权益'; + String get vipOpenNow => '立即开通'; + + @override + String get vipBenefitFree => '全剧免费看'; + + @override + String get vipBenefitNoAds => '免广告打扰'; + + @override + String get vipBenefitHd => '1080P高清'; + + @override + String get vipBenefitFastCache => '极速缓存'; + + @override + String get moreServices => '更多服务'; + + @override + String get mineVipSubtitle => '解锁精彩,畅看好剧'; @override String favoriteEpisodeProgress(int episode, int total) { return '第 $episode 集 / 共 $total 集'; } + @override + String get manage => '管理'; + + @override + String continueWatchingSubtitle(int episode, int total) { + return '看到第 $episode 集 · 全 $total 集'; + } + + @override + String get favoriteDramas => '收藏短剧'; + + @override + String totalDramasCount(int count) { + return '共 $count 部'; + } + + @override + String watchedEpisodeTotal(int episode, int total) { + return '看到第 $episode 集 / 全 $total 集'; + } + + @override + String get hasUpdateBadge => '有更新'; + + @override + String updatedToEpisode(int episode) { + return '更新至第 $episode 集'; + } + + @override + String finishedAllEpisodes(int total) { + return '全 $total 集 · 已完结'; + } + + @override + String get noFavoritesYet => '还没有收藏的短剧'; + @override String watchedToEpisode(int episode) { return '已看至 第 $episode 集'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b94416d..414be2f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -19,8 +19,7 @@ "searchCancel": "取消", "featured": "精选", "recommended": "推荐", - "fruitShortsBrand": "水果短剧", - "authLandingSubtitle": "登录后同步进度、收藏与金币权益", + "fruitShortsBrand": "FruitShorts", "comingSoon": "即将上线", "authLegal": "继续即代表同意《用户协议》与《隐私政策》", "authLegalLead": "继续即代表同意", @@ -55,7 +54,7 @@ "episodes": {"type": "String"} } }, - "feedEpisodePrefix": "第{episode}集|", + "feedEpisodePrefix": "第{episode}集", "@feedEpisodePrefix": { "placeholders": { "episode": {"type": "int"} @@ -254,6 +253,7 @@ "shareFailed": "无法打开系统分享面板。", "nowPlaying": "正在播放", "pictureInPicture": "画中画", + "autoEnterPip": "退出后自动小窗", "pipUnavailable": "此设备无法使用画中画", "originalTag": "原创", "originalProduction": "原创出品", @@ -383,6 +383,7 @@ "restoreDefault": "恢复默认", "invalidServerAddress": "请输入有效的 http(s) 服务器地址。", "signOut": "退出登录", + "signOutConfirm": "确定要退出当前账号吗?", "mockUserName": "Vicky sheng", "mockUserId": "ID: M1CZWVYE", "mockNotice": "当前是示例数据。", @@ -711,8 +712,14 @@ "offlineDownloads": "离线缓存", "watchPreferences": "观看偏好", "contactSupport": "联系客服", - "vipBrandName": "Netshort VIP", - "mineVipSubtitle": "享受专属权益", + "vipBrandName": "FruitShorts VIP", + "vipOpenNow": "立即开通", + "vipBenefitFree": "全剧免费看", + "vipBenefitNoAds": "免广告打扰", + "vipBenefitHd": "1080P高清", + "vipBenefitFastCache": "极速缓存", + "moreServices": "更多服务", + "mineVipSubtitle": "解锁精彩,畅看好剧", "favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集", "@favoriteEpisodeProgress": { "placeholders": { @@ -720,6 +727,44 @@ "total": { "type": "int" } } }, + "myFavorites": "我的收藏", + "manage": "管理", + "continueWatching": "继续观看", + "continueWatchingSubtitle": "看到第 {episode} 集 · 全 {total} 集", + "@continueWatchingSubtitle": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "favoriteDramas": "收藏短剧", + "totalDramasCount": "共 {count} 部", + "@totalDramasCount": { + "placeholders": { + "count": { "type": "int" } + } + }, + "watchedEpisodeTotal": "看到第 {episode} 集 / 全 {total} 集", + "@watchedEpisodeTotal": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "hasUpdateBadge": "有更新", + "updatedToEpisode": "更新至第 {episode} 集", + "@updatedToEpisode": { + "placeholders": { + "episode": { "type": "int" } + } + }, + "finishedAllEpisodes": "全 {total} 集 · 已完结", + "@finishedAllEpisodes": { + "placeholders": { + "total": { "type": "int" } + } + }, + "noFavoritesYet": "还没有收藏的短剧", "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "观看全集", diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart index a2c9e8e..2b23112 100644 --- a/lib/pages/account/account_deactivate_page.dart +++ b/lib/pages/account/account_deactivate_page.dart @@ -11,6 +11,7 @@ import '../../core/formatting/localized_date_time.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; @@ -172,15 +173,15 @@ class _AccountDeactivatePageState extends State { final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(l10n.accountConfirmDeactivateTitle), - content: Text(l10n.accountConfirmDeactivateMessage), + builder: (dialogContext) => AppGlowDialog( + title: l10n.accountConfirmDeactivateTitle, + content: l10n.accountConfirmDeactivateMessage, actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.cancel), ), - FilledButton( + TextButton( key: const Key('account-deactivate-confirm'), onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.accountConfirmDeactivate), diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart index 50ec19c..7402a35 100644 --- a/lib/pages/auth/auth_form_widgets.dart +++ b/lib/pages/auth/auth_form_widgets.dart @@ -61,7 +61,7 @@ class _AuthFields extends StatelessWidget { onChanged: callbacks.onEmailChanged, onSubmitted: (_) => state.fields.passwordFocus.requestFocus(), style: const TextStyle(color: AppColors.onSurface, fontSize: 15), - cursorColor: AppColors.blue, + cursorColor: AppColors.brandRed, decoration: _decoration( context: context, hint: 'name@example.com', @@ -93,7 +93,7 @@ class _AuthFields extends StatelessWidget { onChanged: callbacks.onPasswordChanged, onSubmitted: (_) => callbacks.onSubmit(), style: const TextStyle(color: AppColors.onSurface, fontSize: 15), - cursorColor: AppColors.blue, + cursorColor: AppColors.brandRed, decoration: _decoration( context: context, hint: l10n.authPasswordHint, @@ -128,7 +128,7 @@ class _AuthFields extends StatelessWidget { style: const TextStyle( color: AppColors.muted, fontSize: 12, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, ), ); @@ -142,10 +142,7 @@ class _AuthFields extends StatelessWidget { }) { final border = OutlineInputBorder( borderRadius: BorderRadius.circular(_fieldRadius), - borderSide: const BorderSide( - color: AppColors.border, - width: _fieldBorderWidth, - ), + borderSide: BorderSide.none, ); final errorColor = Theme.of(context).colorScheme.error; final errorBorder = OutlineInputBorder( @@ -158,7 +155,7 @@ class _AuthFields extends StatelessWidget { prefixIcon: Icon( icon, size: 21, - color: focused ? AppColors.blue : AppColors.muted, + color: focused ? AppColors.brandRed : AppColors.muted, ), prefixIconConstraints: const BoxConstraints( minWidth: _minTapTarget, @@ -166,7 +163,7 @@ class _AuthFields extends StatelessWidget { ), suffixIcon: suffix, filled: true, - fillColor: AppColors.card, + fillColor: Colors.white.withValues(alpha: 0.05), isDense: true, contentPadding: const EdgeInsets.only(right: 16), border: border, @@ -175,7 +172,7 @@ class _AuthFields extends StatelessWidget { focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(_fieldRadius), borderSide: const BorderSide( - color: AppColors.blue, + color: AppColors.brandRed, width: _fieldBorderWidth, ), ), @@ -204,17 +201,17 @@ class _AuthSubmitButton extends StatelessWidget { style: ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { - return AppColors.blue.withValues(alpha: 0.5); + return AppColors.brandRed.withValues(alpha: 0.5); } if (states.contains(WidgetState.pressed)) { - return AppColors.bluePressed; + return AppColors.brandRedPressed; } - return AppColors.blue; + return AppColors.brandRed; }), foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary), elevation: const WidgetStatePropertyAll(2), shadowColor: WidgetStatePropertyAll( - AppColors.blue.withValues(alpha: 0.22), + AppColors.brandRed.withValues(alpha: 0.22), ), padding: const WidgetStatePropertyAll(EdgeInsets.zero), shape: WidgetStatePropertyAll( @@ -235,7 +232,7 @@ class _AuthSubmitButton extends StatelessWidget { status.signUpMode ? l10n.signUp : l10n.signIn, style: const TextStyle( fontSize: 15, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w600, ), ), ), @@ -243,18 +240,16 @@ class _AuthSubmitButton extends StatelessWidget { } } -class _ModeSegment extends StatelessWidget { - const _ModeSegment({ +class _ModeTab extends StatelessWidget { + const _ModeTab({ required this.segmentKey, required this.label, - required this.hiddenLabel, required this.selected, required this.onTap, }); final Key segmentKey; final String label; - final String hiddenLabel; final bool selected; final VoidCallback? onTap; @@ -264,43 +259,30 @@ class _ModeSegment extends StatelessWidget { key: segmentKey, behavior: HitTestBehavior.opaque, onTap: onTap, - child: AnimatedContainer( - duration: _segmentDuration, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected ? AppColors.blue : AppColors.transparent, - borderRadius: BorderRadius.circular(18), - ), - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: Text( - label, - style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: selected ? AppColors.textPrimary : AppColors.muted, + fontSize: AppDimens.followingSegmentFont, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap), + Container( + width: AppDimens.followingSegmentIndicatorWidth, + height: AppDimens.followingSegmentIndicatorHeight, + decoration: BoxDecoration( + color: selected ? AppColors.brandRed : AppColors.transparent, + borderRadius: BorderRadius.circular( + AppDimens.followingSegmentIndicatorRadius, ), ), - Positioned.fill( - child: ExcludeSemantics( - child: Opacity( - opacity: 0, - child: Center( - child: Text( - hiddenLabel, - maxLines: 1, - softWrap: false, - style: const TextStyle(fontSize: 14), - ), - ), - ), - ), - ), - ], - ), + ), + ], ), ); } @@ -349,7 +331,7 @@ class _AuthBrand extends StatelessWidget { style: const TextStyle( color: AppColors.onSurface, fontSize: 24, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w700, height: 1.1, ), ), diff --git a/lib/pages/auth/auth_legal_links.dart b/lib/pages/auth/auth_legal_links.dart index ca4b75a..0acc1c9 100644 --- a/lib/pages/auth/auth_legal_links.dart +++ b/lib/pages/auth/auth_legal_links.dart @@ -50,7 +50,7 @@ class _AuthLegalLinksState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final muted = TextStyle(color: AppColors.muted, fontSize: _legalFontSize, height: 1.6); - final link = muted.copyWith(color: AppColors.blue); + final link = muted.copyWith(color: AppColors.brandRed); return Text.rich( TextSpan( style: muted, diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 0f02fb6..0e63c13 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -24,18 +24,14 @@ part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; const double _minTapTarget = 44; -const double _brandMarkSize = 72; +const double _brandMarkSize = 88; const double _compactBrandMarkSize = 48; -const double _brandMarkRadius = 20; +const double _brandMarkRadius = 26; const double _compactBrandMarkRadius = 14; -const double _landingLogoTopGap = 28; const String _appLogoAsset = 'assets/icon/fruitshorts.png'; const double _contentMaxWidth = 440; -// Auth keeps its approved 20px card silhouette. -const double _cardRadius = AppDimens.authCardRadius; -const double _cardPadding = 20; const double _fieldHeight = 52; -const double _fieldRadius = 14; +const double _fieldRadius = 12; const double _fieldBorderWidth = 1; const double _buttonHeight = 50; const double _buttonRadius = 25; @@ -47,14 +43,11 @@ const double _bottomContentPadding = 28; const double _compactTopContentPadding = 40; const double _compactHeightBreakpoint = 720; const double _compactBrandGap = 16; -const double _glowSize = 280; -const double _warmGlowOpacity = 0.18; -const double _entryButtonHeight = 50; -const double _entryButtonRadius = 25; +const double _entryButtonHeight = 52; +const double _entryButtonRadius = 26; const double _entryButtonFontSize = 15; const double _entryIconSize = 20; const Duration _enterDuration = Duration(milliseconds: 220); -const Duration _segmentDuration = Duration(milliseconds: 160); Widget _brandMarkWidget({ Key? key, @@ -267,45 +260,53 @@ class _AuthPageState extends State { child: IgnorePointer( child: DecoratedBox( key: Key('auth-page-background'), - decoration: BoxDecoration(gradient: AppTheme.pageGradient), - ), - ), - ), - Positioned( - top: -90, - right: -70, - width: _glowSize, - height: _glowSize, - child: IgnorePointer( - child: Container( - key: const Key('auth-background-glow'), decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, colors: [ - AppColors.surfaceTop, - AppColors.surfaceTop.withValues(alpha: 0), + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, ], + stops: [0, 0.14, 0.3], ), ), ), ), ), - Positioned( - left: -110, - bottom: -90, - width: _glowSize, - height: _glowSize, + // 全屏海报背景:压暗保证文字可读,顶部再叠品牌红光。 + Positioned.fill( + child: IgnorePointer(child: _AuthPosterBackdrop()), + ), + const Positioned.fill( child: IgnorePointer( - child: Container( - key: const Key('auth-background-warm-glow'), + child: DecoratedBox( + key: Key('auth-poster-scrim'), decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, colors: [ - AppColors.gold.withValues(alpha: _warmGlowOpacity), - AppColors.gold.withValues(alpha: 0), + Color(0x66000000), + Color(0x14000000), + Color(0xE0000000), ], + stops: [0, 0.42, 1], + ), + ), + ), + ), + ), + const Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + key: Key('auth-red-glow'), + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(0, -1.15), + radius: 1.3, + colors: [AppColors.primaryBorder, AppColors.transparent], ), ), ), @@ -347,7 +348,11 @@ class _AuthPageState extends State { constraints: const BoxConstraints( maxWidth: _contentMaxWidth, ), - child: _content(l10n, compact: compact), + child: _content( + l10n, + compact: compact, + minHeight: minHeight, + ), ), ), ), @@ -387,7 +392,11 @@ class _AuthPageState extends State { ); } - Widget _content(AppLocalizations l10n, {required bool compact}) => + Widget _content( + AppLocalizations l10n, { + required bool compact, + required double minHeight, + }) => TweenAnimationBuilder( key: const Key('auth-enter-animation'), tween: Tween(begin: 0, end: 1), @@ -409,15 +418,18 @@ class _AuthPageState extends State { _authCard(l10n), ], ) - : _landing(l10n), + : _landing(l10n, minHeight: minHeight), ); - Widget _landing(AppLocalizations l10n) => Column( + /// 落地页内容需要有限高度才能用 Spacer 三段分割,这里显式钉住可用高度。 + Widget _landing(AppLocalizations l10n, {required double minHeight}) => + SizedBox( + height: minHeight, + child: Column( key: const Key('auth-landing'), - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: _landingLogoTopGap), + const Spacer(flex: 5), Center( child: _brandMarkWidget( key: const Key('auth-brand-logo'), @@ -425,24 +437,19 @@ class _AuthPageState extends State { radius: _brandMarkRadius, ), ), - const SizedBox(height: 16), + const SizedBox(height: 10), Text( l10n.fruitShortsBrand, textAlign: TextAlign.center, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 24, - fontWeight: FontWeight.w900, + fontSize: 26, + fontWeight: FontWeight.w700, + letterSpacing: 2, ), ), - const SizedBox(height: 6), - Text( - l10n.authLandingSubtitle, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.muted, fontSize: 13), - ), - const SizedBox(height: 28), + const Spacer(flex: 3), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, @@ -458,10 +465,12 @@ class _AuthPageState extends State { primary: false, onTap: () => setState(() => _emailEntryOpen = true), ), - const SizedBox(height: 18), - const AuthLegalLinks(), - ], - ); + const SizedBox(height: 14), + const Opacity(opacity: 0.75, child: AuthLegalLinks()), + const Spacer(flex: 6), + ], + ), + ); Future _openPhoneSheet() async { final signedIn = await showPhoneAuthSheet( @@ -526,15 +535,15 @@ class _AuthEntryButton extends StatelessWidget { @override Widget build(BuildContext context) { - final background = primary ? AppColors.blue : AppColors.card; + // 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%),Apple 风格。 + final background = primary + ? AppColors.brandRed + : Colors.white.withValues(alpha: 0.06); final foreground = primary ? AppColors.onPrimary : AppColors.onSurface; return Material( color: background, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_entryButtonRadius), - side: primary - ? BorderSide.none - : const BorderSide(color: AppColors.border), ), clipBehavior: Clip.antiAlias, child: InkWell( @@ -556,7 +565,7 @@ class _AuthEntryButton extends StatelessWidget { style: TextStyle( color: foreground, fontSize: _entryButtonFontSize, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w600, ), ), ), @@ -567,3 +576,18 @@ class _AuthEntryButton extends StatelessWidget { ); } } + +/// 登录页全屏海报背景(单张,无拼缝)。 +class _AuthPosterBackdrop extends StatelessWidget { + const _AuthPosterBackdrop(); + + @override + Widget build(BuildContext context) { + return Image.asset( + key: const Key('auth-poster-backdrop'), + 'assets/auth/bg_3.jpg', + fit: BoxFit.cover, + alignment: Alignment.center, + ); + } +} diff --git a/lib/pages/auth/auth_widgets.dart b/lib/pages/auth/auth_widgets.dart index aaf377a..6630b55 100644 --- a/lib/pages/auth/auth_widgets.dart +++ b/lib/pages/auth/auth_widgets.dart @@ -70,52 +70,45 @@ class _AuthCard extends StatelessWidget { Widget build(BuildContext context) { final l10n = state.l10n; final status = state.status; - return Container( + // 不再做卡片盒子,表单直接排在红光晕背景上。 + return Column( key: const Key('auth-card'), - padding: const EdgeInsets.all(_cardPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(_cardRadius), - border: Border.all(color: AppColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AuthModeSwitch( - l10n: l10n, - signUpMode: status.signUpMode, - busy: status.busy, - onSelected: callbacks.onModeSelected, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthModeSwitch( + l10n: l10n, + signUpMode: status.signUpMode, + busy: status.busy, + onSelected: callbacks.onModeSelected, + ), + const SizedBox(height: 24), + Text( + status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w600, + height: 1.2, ), - const SizedBox(height: 22), - Text( - status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 25, - fontWeight: FontWeight.w800, - height: 1.2, - ), + ), + const SizedBox(height: 6), + Text( + status.signUpMode ? l10n.authSignUpSubtitle : l10n.authSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + height: 1.45, ), - const SizedBox(height: 6), - Text( - status.signUpMode ? l10n.authSignUpSubtitle : l10n.authSubtitle, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.body, - height: 1.45, - ), - ), - const SizedBox(height: 22), - _AuthFields(state: state, callbacks: callbacks), - if (status.errorMessage != null) ...[ - const SizedBox(height: 14), - _AuthError(message: status.errorMessage!), - ], - const SizedBox(height: 22), - _AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit), + ), + const SizedBox(height: 20), + _AuthFields(state: state, callbacks: callbacks), + if (status.errorMessage != null) ...[ + const SizedBox(height: 14), + _AuthError(message: status.errorMessage!), ], - ), + const SizedBox(height: 22), + _AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit), + ], ); } } @@ -135,47 +128,23 @@ class _AuthModeSwitch extends StatelessWidget { @override Widget build(BuildContext context) { - return TextButton( + return Row( key: const Key('auth-switch-mode'), - onPressed: busy ? null : () => onSelected(!signUpMode), - style: TextButton.styleFrom( - minimumSize: const Size(0, _minTapTarget), - padding: EdgeInsets.zero, - overlayColor: AppColors.transparent, - splashFactory: NoSplash.splashFactory, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), - ), - child: Container( - height: 36, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: AppColors.raised, - borderRadius: BorderRadius.circular(18), + children: [ + _ModeTab( + segmentKey: const Key('auth-mode-sign-in'), + label: l10n.signIn, + selected: !signUpMode, + onTap: busy ? null : () => onSelected(false), ), - child: Row( - children: [ - Expanded( - child: _ModeSegment( - segmentKey: const Key('auth-mode-sign-in'), - label: l10n.signIn, - hiddenLabel: l10n.authSwitchToSignIn, - selected: !signUpMode, - onTap: busy ? null : () => onSelected(false), - ), - ), - const SizedBox(width: 4), - Expanded( - child: _ModeSegment( - segmentKey: const Key('auth-mode-sign-up'), - label: l10n.signUp, - hiddenLabel: l10n.authSwitchToSignUp, - selected: signUpMode, - onTap: busy ? null : () => onSelected(true), - ), - ), - ], + const SizedBox(width: AppDimens.followingSegmentGap), + _ModeTab( + segmentKey: const Key('auth-mode-sign-up'), + label: l10n.signUp, + selected: signUpMode, + onTap: busy ? null : () => onSelected(true), ), - ), + ], ); } } diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 9fe651b..5b7b5dd 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -11,6 +11,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/centered_toast.dart'; import '../../l10n/app_localizations.dart'; @@ -252,15 +253,15 @@ class _PhoneAuthSheetState extends State { final bottom = effectiveKeyboardInset(context, _keyboardInset); return Padding( padding: EdgeInsets.only(bottom: bottom), - child: Container( - key: const Key('phone-auth-sheet'), - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical( - top: Radius.circular(_sheetRadius), - ), + child: AppGlowPanel( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), ), - child: SingleChildScrollView( + border: false, + child: Container( + key: const Key('phone-auth-sheet'), + decoration: const BoxDecoration(color: AppColors.transparent), + child: SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.fromLTRB(22, 10, 22, 26), child: Column( @@ -313,6 +314,7 @@ class _PhoneAuthSheetState extends State { ], ), ), + ), ), ); } @@ -403,7 +405,7 @@ class _PhoneAuthSheetState extends State { key: const Key('auth-phone-submit'), onPressed: _submitting ? null : _submit, style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, + backgroundColor: AppColors.brandRed, foregroundColor: AppColors.onPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_buttonHeight / 2), @@ -439,7 +441,7 @@ class _PhoneAuthSheetState extends State { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(_fieldRadius), - borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + borderSide: const BorderSide(color: AppColors.brandRed, width: 1.5), ), ); } diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index e6ada06..f268916 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -16,6 +16,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; @@ -181,15 +182,15 @@ class _DownloadsBody extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(l10n.clearDownloadsTitle), - content: Text(l10n.clearDownloadsMessage), + builder: (dialogContext) => AppGlowDialog( + title: l10n.clearDownloadsTitle, + content: l10n.clearDownloadsMessage, actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.cancel), ), - FilledButton( + TextButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.confirmClear), ), @@ -435,7 +436,7 @@ class _LicenseTile extends StatelessWidget { minHeight: AppDimens.progressHeight, value: progress.clamp(0.0, 1.0), backgroundColor: AppColors.border, - valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), + valueColor: const AlwaysStoppedAnimation(AppColors.brandRed), ), ), if (license.expireAt != null) ...[ diff --git a/lib/pages/interaction/widgets/report_dialog.dart b/lib/pages/interaction/widgets/report_dialog.dart index e6fae25..5ab21cc 100644 --- a/lib/pages/interaction/widgets/report_dialog.dart +++ b/lib/pages/interaction/widgets/report_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/interaction.dart'; +import '../../../core/widgets/app_glow_panel.dart'; import '../../../l10n/app_localizations.dart'; const int _maxDescriptionLength = 300; @@ -67,9 +68,9 @@ class _ReportDialogState extends State<_ReportDialog> { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return AlertDialog( - title: Text(widget.title), - content: SingleChildScrollView( + return AppGlowDialog( + title: widget.title, + contentChild: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index f564a03..d347132 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -13,6 +13,7 @@ import '../../core/formatting/localized_date_time.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; @@ -191,9 +192,9 @@ class _NoticesPageState extends State { : formatLocalizedDateTime(context, notice.publishAt!); return showDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(notice.title), - content: SizedBox( + builder: (dialogContext) => AppGlowDialog( + title: notice.title, + contentChild: SizedBox( width: double.maxFinite, child: SingleChildScrollView( child: Column( diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 4e7204e..5425178 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -14,6 +14,7 @@ import '../../../core/data/repositories/interaction_repository.dart'; import '../../../core/data/services/comment_controller.dart'; import '../../../core/formatting/error_text.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/app_glow_panel.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; import 'comment_composer_sheet.dart'; @@ -176,14 +177,14 @@ class _DramaCommentsSheetState extends State { child: SizedBox( key: const Key('comment-sheet-constraints'), height: sheetHeight, - child: Container( - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical( - top: Radius.circular(_sheetRadius), - ), + child: AppGlowPanel( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), ), - child: SafeArea( + border: false, + child: Container( + decoration: const BoxDecoration(color: AppColors.transparent), + child: SafeArea( top: false, child: ListenableBuilder( listenable: _controller, @@ -193,6 +194,7 @@ class _DramaCommentsSheetState extends State { child: _content(context), ); }, + ), ), ), ), @@ -238,7 +240,7 @@ class _DramaCommentsSheetState extends State { style: const TextStyle( color: AppColors.onSurface, fontSize: 16, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w400, ), ), Positioned( diff --git a/lib/pages/player/widgets/episode_picker_sheet.dart b/lib/pages/player/widgets/episode_picker_sheet.dart index df4b9b6..2a4fd39 100644 --- a/lib/pages/player/widgets/episode_picker_sheet.dart +++ b/lib/pages/player/widgets/episode_picker_sheet.dart @@ -7,6 +7,7 @@ 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'; @@ -47,16 +48,16 @@ class EpisodePickerSheet extends StatelessWidget { systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ), - child: Container( - key: const Key('player-episodes-sheet'), - height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical( - top: Radius.circular(_sheetRadius), - ), + child: AppGlowPanel( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), ), - child: SafeArea( + 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( @@ -139,6 +140,7 @@ class EpisodePickerSheet extends StatelessWidget { ], ), ), + ), ), ); } diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index f6fbdad..033da56 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/app_glow_panel.dart'; import '../../../l10n/app_localizations.dart'; const double _sheetRadius = 20; @@ -52,6 +53,8 @@ class PlayerMoreActions { required this.onDownload, required this.onQualityChanged, this.onPip, + this.autoPipEnabled = false, + this.onAutoPipChanged, }); /// 倍速变化。 @@ -68,6 +71,12 @@ class PlayerMoreActions { /// 进入小窗播放;平台不支持时为 null。 final VoidCallback? onPip; + + /// 退出 App 自动小窗开关的当前状态(仅支持自动小窗的平台展示开关行)。 + final bool autoPipEnabled; + + /// 切换退出 App 自动小窗;为 null 时隐藏开关行。 + final ValueChanged? onAutoPipChanged; } /// K29.6 精简版播放页更多面板。 @@ -96,16 +105,19 @@ class PlayerMoreSheet extends StatefulWidget { class _PlayerMoreSheetState extends State { late double _speed = widget.state.speed; late String _quality = widget.state.selectedQuality.toUpperCase(); + late bool _autoPip = widget.actions.autoPipEnabled; @override Widget build(BuildContext context) { final l10n = widget.l10n; - return Material( - key: const Key('player-more-sheet'), - color: AppColors.ink, + return AppGlowPanel( borderRadius: const BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), + border: false, + child: Material( + key: const Key('player-more-sheet'), + type: MaterialType.transparency, clipBehavior: Clip.antiAlias, child: SafeArea( top: false, @@ -142,6 +154,17 @@ class _PlayerMoreSheetState extends State { title: l10n.pictureInPicture, onTap: () => _closeThen(widget.actions.onPip!), ), + if (widget.actions.onAutoPipChanged != null) + _switchRow( + key: const Key('player-more-auto-pip'), + icon: Icons.auto_awesome_motion_outlined, + title: l10n.autoEnterPip, + value: _autoPip, + onChanged: (value) { + setState(() => _autoPip = value); + widget.actions.onAutoPipChanged!(value); + }, + ), _actionRow( key: const Key('player-more-offline'), icon: Icons.download_outlined, @@ -153,6 +176,7 @@ class _PlayerMoreSheetState extends State { ), ), ), + ), ); } @@ -160,7 +184,7 @@ class _PlayerMoreSheetState extends State { width: double.infinity, padding: const EdgeInsets.symmetric(vertical: _groupVerticalPadding), decoration: BoxDecoration( - color: AppColors.card, + color: Colors.white.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(_groupRadius), ), child: Column(mainAxisSize: MainAxisSize.min, children: rows), @@ -241,7 +265,7 @@ class _PlayerMoreSheetState extends State { horizontal: _segmentTrackPadding, ), decoration: BoxDecoration( - color: AppColors.raised, + color: Colors.white.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(_segmentTrackRadius), ), child: Row( @@ -286,6 +310,54 @@ class _PlayerMoreSheetState extends State { ); } + /// 开关行:左侧图标+标题,右侧主题色 Switch。 + Widget _switchRow({ + required Key key, + required IconData icon, + required String title, + required bool value, + required ValueChanged onChanged, + }) { + return SizedBox( + key: key, + height: _rowHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _rowHorizontalPadding), + child: Row( + children: [ + Icon(icon, color: AppColors.onSurface, size: _rowIconSize), + const SizedBox(width: _rowIconGap), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: _rowLabelSize, + fontWeight: FontWeight.w500, + ), + ), + ), + Transform.scale( + scale: 0.7, + child: Switch( + key: const Key('player-more-auto-pip-switch'), + value: value, + activeThumbColor: AppColors.onSurface, + activeTrackColor: AppColors.brandRed, + inactiveThumbColor: AppColors.textSecondary, + inactiveTrackColor: AppColors.contentBorder, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: onChanged, + ), + ), + ], + ), + ), + ); + } + Widget _actionRow({ required Key key, required IconData icon, @@ -380,7 +452,9 @@ class _SegmentButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: _segmentPadding), alignment: Alignment.center, decoration: BoxDecoration( - color: selected ? AppColors.card : AppColors.transparent, + color: selected + ? Colors.white.withValues(alpha: 0.16) + : AppColors.transparent, borderRadius: BorderRadius.circular(_segmentRadius), boxShadow: selected ? const [ diff --git a/lib/pages/player/widgets/player_speed_sheet.dart b/lib/pages/player/widgets/player_speed_sheet.dart index 475ac5c..e881ce1 100644 --- a/lib/pages/player/widgets/player_speed_sheet.dart +++ b/lib/pages/player/widgets/player_speed_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/app_glow_panel.dart'; import '../../../l10n/app_localizations.dart'; const List _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; @@ -19,14 +20,16 @@ class PlayerSpeedSheet extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Material( - key: const Key('player-speed-sheet'), - color: AppColors.card, + return AppGlowPanel( borderRadius: const BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), - clipBehavior: Clip.antiAlias, - child: SafeArea( + border: false, + child: Material( + key: const Key('player-speed-sheet'), + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + child: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, @@ -50,6 +53,7 @@ class PlayerSpeedSheet extends StatelessWidget { ], ), ), + ), ); } diff --git a/lib/pages/player/widgets/player_unlock_sheet.dart b/lib/pages/player/widgets/player_unlock_sheet.dart index 8b2a9c1..55de30d 100644 --- a/lib/pages/player/widgets/player_unlock_sheet.dart +++ b/lib/pages/player/widgets/player_unlock_sheet.dart @@ -6,6 +6,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/data/models/play_ticket.dart'; import '../../../core/formatting/error_text.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/app_glow_panel.dart'; import '../../../l10n/app_localizations.dart'; const double _cardRadius = 20; @@ -209,12 +210,10 @@ class _PlayerUnlockSheetState extends State { @override Widget build(BuildContext context) { final l10n = widget.l10n; - return Container( + return AppGlowPanel( key: const Key('player-unlock-panel'), - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical(top: Radius.circular(_cardRadius)), - ), + borderRadius: const BorderRadius.vertical(top: Radius.circular(_cardRadius)), + border: false, child: SafeArea( top: false, child: Padding( diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 1d8c471..d8e2e7d 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -14,6 +14,7 @@ import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_list_footer.dart'; @@ -255,15 +256,15 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(l10n.clearWatchHistoryTitle), - content: Text(l10n.clearWatchHistoryMessage), + builder: (dialogContext) => AppGlowDialog( + title: l10n.clearWatchHistoryTitle, + content: l10n.clearWatchHistoryMessage, actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.cancel), ), - FilledButton( + TextButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.confirmClear), ), From 5346be40a9b27dfd3172858bd5e10201901e3b14 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Wed, 28 Oct 2026 16:36:51 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(ui):=20=E6=89=93=E7=A3=A8=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E8=BF=BD=E5=89=A7=E6=88=91=E7=9A=84=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E9=97=B4=E8=B7=9D=E4=B8=8E=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 26 +- lib/core/theme/app_dimens.dart | 78 ++- lib/core/theme/app_theme.dart | 22 +- lib/core/widgets/plain_page_scaffold.dart | 15 +- lib/core/widgets/remote_image.dart | 10 +- lib/core/widgets/subpage_header.dart | 4 +- lib/pages/account/account_entry_card.dart | 5 +- lib/pages/discover/search_page.dart | 2 +- .../widgets/drama_detail_bottom_bar.dart | 2 +- .../widgets/drama_detail_header.dart | 14 +- lib/pages/following/following_edit_bar.dart | 90 +-- .../following/following_favorites_grid.dart | 99 +++- lib/pages/following/following_grid_card.dart | 53 +- .../following/following_history_card.dart | 2 +- lib/pages/following/following_page.dart | 258 +++++---- lib/pages/following/following_paged_grid.dart | 6 + .../widgets/continue_watching_banner.dart | 207 +++++++ lib/pages/home/home_view.dart | 11 +- lib/pages/mine/mine_view.dart | 230 +++----- lib/pages/mine/widgets/mine_header.dart | 51 +- lib/pages/mine/widgets/mine_menu.dart | 522 ++++++++++-------- .../mine/widgets/mine_more_services.dart | 93 ---- lib/pages/new_releases/new_releases_page.dart | 14 +- lib/pages/recommend/recommend_page.dart | 44 +- .../widgets/all_dramas_filter_grid.dart | 6 +- .../widgets/playlist_detail_page.dart | 23 +- .../widgets/recommend_channel_nav.dart | 13 +- .../widgets/recommend_sections_view.dart | 10 +- .../recommend/widgets/recommend_top_nav.dart | 13 +- .../recommend/widgets/search_gift_glyphs.dart | 78 +++ lib/pages/settings/legal_links_card.dart | 21 +- lib/pages/settings/settings_page.dart | 195 ++++++- .../widgets/watch_record_card.dart | 2 +- 33 files changed, 1408 insertions(+), 811 deletions(-) create mode 100644 lib/pages/following/widgets/continue_watching_banner.dart delete mode 100644 lib/pages/mine/widgets/mine_more_services.dart create mode 100644 lib/pages/recommend/widgets/search_gift_glyphs.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 1bfe192..49d2071 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -27,13 +27,13 @@ abstract final class AppColors { static const overlaySecondary = Color(0xFFC7C7CC); static const overlayLabel = Color(0xFFE6E6EB); - /// 主操作、选中态、播放进度(品牌橙)。 - static const blue = Color(0xFFFF6B2C); + /// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。 + static const blue = Color(0xFFF04E4E); static const primary = blue; static const actionRed = Color(0xFFFF3B30); /// 主操作按下态。 - static const bluePressed = Color(0xFFE55A1F); + static const bluePressed = Color(0xFFD64040); /// 链接与次要高亮:统一改用中性浅灰,避免旧青色残留。 static const blueSoft = Color(0xFFB0B0B0); @@ -47,6 +47,10 @@ abstract final class AppColors { static const homeGiftPink = Color(0xFFFF4D8D); static const vipGoldStart = Color(0xFFF5D9A0); static const vipGoldEnd = Color(0xFFE8B65A); + + /// 「我的」页 VIP 卡的浅香槟金(见设计稿,接近纯色)。 + static const vipCardStart = Color(0xFFF6E8C6); + static const vipCardEnd = Color(0xFFEDD9A8); static const vipBadgeSurface = Color(0x1F3D2408); static const bronze = Color(0xFFCD7F32); static const bronzeDeep = Color(0xFFA9713A); @@ -75,6 +79,15 @@ abstract final class AppColors { /// 首页/追剧/我的统一的纯黑内容背景。 static const appBackground = Color(0xFF000000); + /// 追剧页顶部的暗红光晕,向下淡入纯黑(见设计稿)。 + static const followingGlow = Color(0xFF4C161A); + + /// 光晕的过渡色:让暖色维持更久再压黑。 + static const followingGlowMid = Color(0xFF1E0B0C); + + /// 继续观看横幅剧名的暖白(见设计稿,非纯白)。 + static const bannerTitleWarm = Color(0xFFF5EFE4); + /// 中性深灰卡片与控件表面。 static const contentCard = Color(0xFF16161A); static const contentSurface = Color(0xFF1C1C1E); @@ -96,7 +109,7 @@ abstract final class AppColors { static const mineGradientTop = Color(0xFF3A1B0E); static const mineGradientMiddle = Color(0xFF1A0C07); static const mineGradientBottom = Color(0xFF000000); - static const mineGradientGlow = Color(0x33FF6B2C); + static const mineGradientGlow = Color(0x33F04E4E); static const mineGridSurface = Color(0x99101010); static const minePanelSurface = Color(0xFF101010); static const mineMoreDivider = Color(0xFF1C1C1E); @@ -111,7 +124,7 @@ abstract final class AppColors { static const mineIconLanguage = Color(0xFF22D3EE); static const mineIconSettings = Color(0xFFA1A1AA); static const mineIconNotices = Color(0xFFFF8A4C); - static const recommendPlaylistGlow = Color(0x26FF6B2C); + static const recommendPlaylistGlow = Color(0x26F04E4E); /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); @@ -136,6 +149,7 @@ abstract final class AppColors { /// 底部导航专用:纯黑背景、选中品牌红与未选中白。 static const navBackground = Color(0xFF000000); static const brandRed = Color(0xFFF04E4E); + static const brandRedPressed = Color(0xFFD64040); static const navSelected = brandRed; /// 卡片阴影(深色几乎不用阴影)。 @@ -214,7 +228,7 @@ abstract final class AppColors { static const bannerScrimEnd = Color(0xE6000000); /// 票券虚线等主色描边。 - static const primaryBorder = Color(0x66FF6B2C); + static const primaryBorder = Color(0x66F04E4E); static const outlineDark = border; static const outlineVariantDark = border; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 9f42692..8feec71 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -146,10 +146,12 @@ abstract final class AppDimens { static const double homeActionGap = 6; /// 推荐分区。 + static const double recommendSectionGap = 12; static const double recommendSectionTitle = 17; static const double recommendSectionSubtitle = 11; static const double recommendSectionMore = 12; - static const double recommendRowHeight = 252; + // 封面 122*(4/3)≈163 + 间距6 + 两行标题39 + 间距6 + 标签13 ≈ 227。 + static const double recommendRowHeight = 228; static const double recommendCardWidth = 122; /// 首页热播榜。 @@ -169,25 +171,26 @@ abstract final class AppDimens { /// 全部短剧筛选与追剧双列。 static const double filterChipFont = 16; - static const double filterChipHeight = 32; - static const double filterChipRadius = 16; + static const double filterChipHeight = 28; + static const double filterChipRadius = 14; static const double filterChipHorizontalPadding = 12; static const double filterRowGap = 6; static const double filterItemGap = 10; static const double allDramasHorizontal = 12; static const double allDramasGridGap = 6; - static const double allDramasGridRowGap = 12; + static const double allDramasGridRowGap = 6; static const double allDramasFilterToGridGap = 12; static const double followingHeaderHeight = 52; static const double followingTabFont = 18; static const double followingTabGap = 24; static const double followingEditIconSize = 20; static const double followingEditHitSize = 40; - static const double followingAddIconSize = 28; + static const double followingAddIconSize = 20; static const double followingAddIconStroke = 1.5; static const double followingGridGap = 6; static const double followingGridRowGap = 12; - static const double followingCardTextExtent = 70; + // 单行标题 16*1.3 + 间距 6+4 + 副标题约 16 ≈ 47。 + static const double followingCardTextExtent = 48; static const double followingSubtitleFont = 12; static const double followingSubtitleGap = 4; static const double followingProgressHeight = 2; @@ -201,6 +204,47 @@ abstract final class AppDimens { static const double followingHistoryProgressGap = 8; static const double followingHistoryLabelGap = 4; static const double followingHistoryActionHeight = 28; + + /// 追剧页新版头部与收藏布局。 + static const double followingTitleFont = 26; + static const double followingTitleRowHeight = 42; + static const double followingTitleTopGap = 8; + static const double followingSearchIcon = 26; + static const double followingManageFont = 16; + static const double followingHeaderActionGap = 8; + static const double followingSegmentFont = 16; + static const double followingSegmentGap = 28; + static const double followingSegmentRowHeight = 42; + static const double followingSegmentIndicatorWidth = 24; + static const double followingSegmentIndicatorHeight = 3; + static const double followingSegmentIndicatorRadius = 2; + static const double followingSegmentIndicatorTopGap = 6; + static const double followingBannerHeight = 176; + static const double followingBannerRadius = 16; + static const double followingBannerEdgeGap = 16; + static const double followingBannerInnerGap = 16; + static const double followingBannerTitleFont = 22; + static const double followingBannerSubtitleFont = 13; + static const double followingBannerMetaGap = 8; + static const double followingBannerButtonHeight = 40; + static const double followingBannerButtonRadius = 20; + static const double followingBannerButtonFont = 15; + static const double followingBannerButtonIcon = 18; + static const double followingBannerButtonGap = 6; + static const double followingBannerButtonHorizontalPadding = 20; + static const double followingBannerButtonTopGap = 14; + static const double followingBannerProgressHeight = 3; + static const double followingSectionTitleFont = 16; + static const double followingSectionCountFont = 13; + static const double followingSectionTopGap = 20; + static const double followingSectionBottomGap = 12; + static const double followingCardTitleFontNew = 16; + static const double followingCardSubtitleFontNew = 13; + static const double followingBadgeFont = 11; + static const double followingBadgeRadius = 6; + static const double followingBadgeInset = 8; + static const double followingBadgePaddingH = 8; + static const double followingBadgePaddingV = 3; static const Duration followingTabAnimationDuration = Duration( milliseconds: 180, ); @@ -211,8 +255,8 @@ abstract final class AppDimens { /// 「我的」页中性视觉。 static const double mineCardRadius = 16; static const double mineSectionTitle = 15; - static const double mineAvatar = 56; - static const double mineUserName = 20; + static const double mineAvatar = 64; + static const double mineUserName = 24; static const double mineUserNameLineHeight = 1.15; static const double mineUserNameUidGap = 2; static const double mineUid = 12; @@ -227,7 +271,23 @@ abstract final class AppDimens { static const double mineGridDividerWidth = 1; static const double mineSectionTitleFont = 16; static const double mineSectionLinkFont = 12; - static const double mineSectionGap = 20; + static const double mineSectionGap = 12; + + /// 「我的」页新版:快捷图标行与服务列表。 + static const double mineQuickActionIcon = 28; + static const double mineQuickActionLabelFont = 14; + static const double mineQuickActionGap = 8; + static const double mineServiceRowHeight = 56; + static const double mineServiceIcon = 24; + static const double mineServiceTitleFont = 16; + static const double mineServiceValueFont = 14; + static const double mineVipBenefitFont = 14; + static const double mineVipBenefitIcon = 20; + static const double mineVipBenefitBox = 30; + static const double mineVipBenefitBoxRadius = 7; + static const double mineVipBenefitGlyph = 17; + static const double mineVipBenefitRowGap = 4; + static const double mineVipButtonHeight = 36; static const double mineContinueCardWidth = 96; static const double mineContinueRowHeight = 172; static const double mineContinueTitleFont = 13; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 0f1789a..f0d351f 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -40,7 +40,7 @@ abstract final class AppTheme { /// 全局深色主题。 static ThemeData dark() { const scheme = ColorScheme.dark( - primary: AppColors.blue, + primary: AppColors.brandRed, onPrimary: AppColors.onPrimary, secondary: AppColors.secondary, onSecondary: AppColors.ink, @@ -54,7 +54,7 @@ abstract final class AppTheme { outline: AppColors.border, outlineVariant: AppColors.border, surfaceTint: AppColors.transparent, - inversePrimary: AppColors.bluePressed, + inversePrimary: AppColors.brandRedPressed, error: AppColors.error, onError: AppColors.onPrimary, ); @@ -66,16 +66,16 @@ abstract final class AppTheme { canvasColor: AppColors.ink, fontFamily: 'PlusJakartaSans', progressIndicatorTheme: const ProgressIndicatorThemeData( - color: AppColors.blue, + color: AppColors.brandRed, ), sliderTheme: const SliderThemeData( - activeTrackColor: AppColors.blue, + activeTrackColor: AppColors.brandRed, inactiveTrackColor: AppColors.border, thumbColor: AppColors.onPrimary, ), tabBarTheme: const TabBarThemeData( - labelColor: AppColors.blue, - indicatorColor: AppColors.blue, + labelColor: AppColors.brandRed, + indicatorColor: AppColors.brandRed, unselectedLabelColor: AppColors.muted, ), textButtonTheme: TextButtonThemeData( @@ -138,12 +138,12 @@ abstract final class AppTheme { ), focusedBorder: OutlineInputBorder( borderRadius: _inputRadius, - borderSide: const BorderSide(color: AppColors.blue), + borderSide: const BorderSide(color: AppColors.brandRed), ), ), chipTheme: ChipThemeData( backgroundColor: AppColors.raised, - selectedColor: AppColors.blue.withValues(alpha: 0.22), + selectedColor: AppColors.brandRed.withValues(alpha: 0.22), labelStyle: const TextStyle(color: AppColors.onSurface), secondaryLabelStyle: const TextStyle(color: AppColors.muted), side: const BorderSide(color: AppColors.border), @@ -216,7 +216,7 @@ abstract final class AppTheme { ), trackColor: WidgetStateProperty.resolveWith( (states) => - states.contains(WidgetState.selected) ? AppColors.blue : null, + states.contains(WidgetState.selected) ? AppColors.brandRed : null, ), ), snackBarTheme: const SnackBarThemeData( @@ -273,8 +273,8 @@ abstract final class AppTheme { if (states.contains(WidgetState.pressed) || states.contains(WidgetState.hovered) || states.contains(WidgetState.focused)) { - return AppColors.bluePressed; + return AppColors.brandRedPressed; } - return AppColors.blue; + return AppColors.brandRed; } } diff --git a/lib/core/widgets/plain_page_scaffold.dart b/lib/core/widgets/plain_page_scaffold.dart index 29c83e4..4918eb2 100644 --- a/lib/core/widgets/plain_page_scaffold.dart +++ b/lib/core/widgets/plain_page_scaffold.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; -import '../theme/app_theme.dart'; import 'subpage_header.dart'; /// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。 @@ -29,7 +28,19 @@ class PlainPageScaffold extends StatelessWidget { key: pageKey, backgroundColor: AppColors.ink, body: DecoratedBox( - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + // 与首页各 tab 同族:纯黑底 + 顶部暗红光晕。 + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 4462959..e6594df 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -74,9 +74,13 @@ class RemoteImage extends StatelessWidget { : null); final cacheWidth = decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio); - final cacheHeight = - decodeHeight ?? - (inferredHeight == null + // cover/contain 本身会保持宽高比,此时若同时强制 cacheWidth 和 + // cacheHeight,解码会把竖版图强行拉成横版像素。只在显式指定了 + // decodeHeight、或 fit 不保持宽高比时才限定解码高度。 + final keepsAspectRatio = + fit == BoxFit.cover || fit == BoxFit.contain; + final cacheHeight = decodeHeight ?? + (keepsAspectRatio || inferredHeight == null ? null : resolveImageDecodePixels(inferredHeight, pixelRatio)); return Image.network( diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index acdfb12..fedb316 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -8,7 +8,7 @@ const double _height = 52; const double _heightWithSubtitle = 64; const double _horizontal = 14; const double _backSize = 40; -const double _backGap = 12; +const double _backGap = 6; const double _titleFontSize = 18; const double _subtitleFontSize = 12; const double _actionGap = 8; @@ -163,7 +163,7 @@ class AppSubpageHeader extends StatelessWidget { style: TextStyle( color: foreground, fontSize: _titleFontSize, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w400, ), ), if (subtitle != null) ...[ diff --git a/lib/pages/account/account_entry_card.dart b/lib/pages/account/account_entry_card.dart index 8b21ec3..16204a7 100644 --- a/lib/pages/account/account_entry_card.dart +++ b/lib/pages/account/account_entry_card.dart @@ -45,8 +45,7 @@ class AccountEntryCard extends StatelessWidget { ]; return Material( key: const Key('mine-account-card'), - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + color: AppColors.contentSurface, clipBehavior: Clip.antiAlias, child: Column( children: [ @@ -55,7 +54,7 @@ class AccountEntryCard extends StatelessWidget { const Divider( height: AppDimens.mineGridDividerWidth, thickness: AppDimens.mineGridDividerWidth, - color: AppColors.outlineVariantDark, + color: AppColors.contentBorder, ), _AccountListTile(row: rows[index]), ], diff --git a/lib/pages/discover/search_page.dart b/lib/pages/discover/search_page.dart index c0b149f..5b406d4 100644 --- a/lib/pages/discover/search_page.dart +++ b/lib/pages/discover/search_page.dart @@ -139,7 +139,7 @@ class _SearchPageState extends State { color: AppColors.textSecondary, size: 18, ), - const SizedBox(width: AppDimens.spaceSm), + const SizedBox(width: 2), Expanded( child: TextField( key: const Key('discover-search'), diff --git a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart index e2852e2..423fd12 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart @@ -95,7 +95,7 @@ class DramaResumeBar extends StatelessWidget { value: fraction, minHeight: 3, backgroundColor: AppColors.border, - valueColor: const AlwaysStoppedAnimation(AppColors.blue), + valueColor: const AlwaysStoppedAnimation(AppColors.brandRed), ), ), if (label != null) ...[ diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index 5c27728..9b08c09 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/subpage_header.dart'; import '../../../l10n/app_localizations.dart'; @@ -54,7 +53,18 @@ class DramaDetailHeader extends StatelessWidget { Widget build(BuildContext context) { return Container( key: const Key('detail-hero'), - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( bottom: false, child: Padding( diff --git a/lib/pages/following/following_edit_bar.dart b/lib/pages/following/following_edit_bar.dart index 54c0643..c440769 100644 --- a/lib/pages/following/following_edit_bar.dart +++ b/lib/pages/following/following_edit_bar.dart @@ -25,87 +25,6 @@ const double _barHorizontal = 16; const double _barFontSize = 13; const double _barButtonHeight = 30; const double _removeDialogButtonHeight = 32; -const double _removeDialogRadius = 6; -const double _removeDialogBottomInset = 8; -const double _cancelFollowingDialogPadding = 20; - -/// 播放历史移除确认。只把移除按钮压矮。 -Future confirmHistoryRemove(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - return await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - actionsPadding: const EdgeInsets.fromLTRB( - 24, - 0, - 16, - _removeDialogBottomInset, - ), - content: Text(l10n.confirmRemove), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(0, _removeDialogButtonHeight), - padding: const EdgeInsets.symmetric(horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_removeDialogRadius), - ), - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.remove), - ), - ], - ), - ) ?? - false; -} - -/// 确认后返回是否取消已选追剧。 -Future confirmCancelFollowing(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - return await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - contentPadding: const EdgeInsets.all(_cancelFollowingDialogPadding), - actionsPadding: const EdgeInsets.fromLTRB( - _cancelFollowingDialogPadding, - 0, - _cancelFollowingDialogPadding, - _cancelFollowingDialogPadding, - ), - content: Text(l10n.confirmCancelFollowing), - actions: [ - TextButton( - style: TextButton.styleFrom( - visualDensity: VisualDensity.standard, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(0, _removeDialogButtonHeight), - padding: const EdgeInsets.symmetric(horizontal: 12), - ), - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - style: FilledButton.styleFrom( - visualDensity: VisualDensity.standard, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(0, _removeDialogButtonHeight), - padding: const EdgeInsets.symmetric(horizontal: 12), - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.cancelFollowing), - ), - ], - ), - ) ?? - false; -} /// 只在追剧编辑中显示底栏。 class FollowingEditSlot extends StatelessWidget { @@ -195,6 +114,7 @@ class FollowingEditBar extends StatelessWidget { TextButton( key: const Key('following-select-all'), style: TextButton.styleFrom( + foregroundColor: AppColors.brandRed, visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, minimumSize: const Size(0, _barButtonHeight), @@ -214,16 +134,14 @@ class FollowingEditBar extends StatelessWidget { ), ), const Spacer(), - FilledButton( + TextButton( key: const Key('following-cancel-follow'), - style: FilledButton.styleFrom( + style: TextButton.styleFrom( + foregroundColor: AppColors.brandRed, visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, minimumSize: const Size(0, _removeDialogButtonHeight), padding: const EdgeInsets.symmetric(horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_removeDialogRadius), - ), textStyle: const TextStyle( fontSize: _barFontSize, fontWeight: FontWeight.w600, diff --git a/lib/pages/following/following_favorites_grid.dart b/lib/pages/following/following_favorites_grid.dart index 5839090..19885d8 100644 --- a/lib/pages/following/following_favorites_grid.dart +++ b/lib/pages/following/following_favorites_grid.dart @@ -5,12 +5,18 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../../core/data/models/watch_record.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; import 'following_paged_grid.dart'; +import 'widgets/continue_watching_banner.dart'; /// 追剧编辑中的选中状态。 class FollowingFavoriteSelection { @@ -30,15 +36,19 @@ class FollowingFavoritesGrid extends StatelessWidget { const FollowingFavoritesGrid({ super.key, required this.controller, + required this.history, required this.selection, required this.scrollController, required this.onGoHome, + required this.onContinueRecord, }); final FavoriteController controller; + final WatchRecordsController history; final FollowingFavoriteSelection selection; final ScrollController scrollController; final VoidCallback onGoHome; + final ValueChanged onContinueRecord; @override Widget build(BuildContext context) { @@ -49,7 +59,7 @@ class FollowingFavoritesGrid extends StatelessWidget { valueListenable: selection.selected, builder: (context, picked, _) { return ListenableBuilder( - listenable: controller, + listenable: Listenable.merge([controller, history]), builder: (context, _) => _grid(context, isEditing, picked), ); }, @@ -60,14 +70,15 @@ class FollowingFavoritesGrid extends StatelessWidget { Widget _grid(BuildContext context, bool isEditing, Set picked) { final l10n = AppLocalizations.of(context)!; - final extra = isEditing ? 0 : 1; + final hasFavorites = controller.cards.isNotEmpty; + final extra = !isEditing && hasFavorites ? 1 : 0; return FollowingPagedGrid( key: const Key('following-favorites-grid'), scrollController: scrollController, loading: controller.loading, loadingMore: controller.loadingMore, hasMore: controller.hasMore, - hasItems: controller.cards.isNotEmpty, + hasItems: hasFavorites, showInitialLoading: false, errorMessage: controller.error == null ? null @@ -78,23 +89,96 @@ class FollowingFavoritesGrid extends StatelessWidget { onRefresh: controller.load, onLoadMore: controller.loadMore, itemCount: controller.cards.length + extra, - empty: const SizedBox.shrink(), + topSlivers: _topSlivers(context), + empty: EmptyState( + key: const Key('following-favorites-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noFavoritesYet, + ), itemBuilder: (context, index) => _item(context, index, isEditing, picked), ); } + List _topSlivers(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final continueRecord = _continueRecord(); + return [ + if (continueRecord != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + 4, + AppDimens.feedPageHorizontal, + 0, + ), + child: ContinueWatchingBanner( + record: continueRecord, + languageCode: Localizations.localeOf(context).languageCode, + onTap: () => onContinueRecord(continueRecord), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + AppDimens.followingSectionTopGap, + AppDimens.feedPageHorizontal, + AppDimens.followingSectionBottomGap, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + l10n.favoriteDramas, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingSectionTitleFont, + fontWeight: FontWeight.w400, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + l10n.totalDramasCount(controller.total), + style: const TextStyle( + color: AppColors.textTabInactive, + fontSize: AppDimens.followingSectionCountFont, + ), + ), + ), + ], + ), + ), + ), + ]; + } + + /// 最近一条观看记录(records 已按最近观看排序,与观看历史页首条一致)。 + WatchRecord? _continueRecord() { + return history.records.isEmpty ? null : history.records.first; + } + Widget _item( BuildContext context, int index, bool isEditing, Set picked, ) { + final l10n = AppLocalizations.of(context)!; if (index == controller.cards.length) { return FollowingAddCard(onTap: onGoHome); } - final l10n = AppLocalizations.of(context)!; 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), @@ -102,7 +186,10 @@ class FollowingFavoritesGrid extends StatelessWidget { viewCount: drama.viewCount, editing: isEditing, selected: isEditing ? picked.contains(drama.id) : null, - subtitle: l10n.favoriteEpisodeProgress(episode, drama.episodeCount), + subtitle: subtitle, + // TODO(backend): Drama 增加 hasUpdate / latestEpisodeNumber / isFinished + // 字段后,在此传 updateBadge(l10n.hasUpdateBadge)、红色更新副标题 + // (l10n.updatedToEpisode)或灰色完结副标题(l10n.finishedAllEpisodes)。 onTap: () => _openOrToggle(context, drama.id, isEditing), ); } diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index 870831a..bdf7d85 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -25,6 +25,8 @@ class FollowingGridCard extends StatelessWidget { this.editing = false, this.selected, this.onRemove, + this.updateBadge, + this.subtitleColor, }); final String dramaId; @@ -38,6 +40,13 @@ class FollowingGridCard extends StatelessWidget { final bool? selected; final VoidCallback? onRemove; + /// 左上角「有更新」角标文案;null 不显示。 + /// 待后端在 Drama 增加更新状态字段后由收藏网格传入。 + final String? updateBadge; + + /// 副标题颜色;更新中用强调红,完结用默认灰。 + final Color? subtitleColor; + @override Widget build(BuildContext context) { return InkWell( @@ -109,6 +118,32 @@ class FollowingGridCard extends StatelessWidget { count: viewCount, ), ), + if (updateBadge != null && selected == null) + Positioned( + left: AppDimens.followingBadgeInset, + top: AppDimens.followingBadgeInset, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.followingBadgePaddingH, + vertical: AppDimens.followingBadgePaddingV, + ), + decoration: BoxDecoration( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular( + AppDimens.followingBadgeRadius, + ), + ), + child: Text( + updateBadge!, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: AppDimens.followingBadgeFont, + fontWeight: FontWeight.w400, + height: 1, + ), + ), + ), + ), if (progress != null) Positioned( left: 0, @@ -120,7 +155,7 @@ class FollowingGridCard extends StatelessWidget { minHeight: AppDimens.followingProgressHeight, backgroundColor: AppColors.inactiveTrack, valueColor: const AlwaysStoppedAnimation( - AppColors.primary, + AppColors.brandRed, ), ), ), @@ -130,13 +165,13 @@ class FollowingGridCard extends StatelessWidget { const SizedBox(height: AppDimens.cardCoverTitleGap), Text( title, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, + fontSize: AppDimens.followingCardTitleFontNew, + fontWeight: FontWeight.w400, height: AppDimens.cardTitleLineHeight, ), ), @@ -146,9 +181,9 @@ class FollowingGridCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.followingSubtitleFont, + style: TextStyle( + color: subtitleColor ?? AppColors.textTertiary, + fontSize: AppDimens.followingCardSubtitleFontNew, ), ), ], @@ -169,9 +204,9 @@ class _SelectMark extends StatelessWidget { height: _selectMarkSize, decoration: BoxDecoration( shape: BoxShape.circle, - color: selected ? AppColors.blue : AppColors.transparent, + color: selected ? AppColors.brandRed : AppColors.transparent, border: Border.all( - color: selected ? AppColors.blue : AppColors.textPrimary, + color: selected ? AppColors.brandRed : AppColors.textPrimary, width: _selectBorderWidth, ), ), diff --git a/lib/pages/following/following_history_card.dart b/lib/pages/following/following_history_card.dart index e2a2af3..d47bbdf 100644 --- a/lib/pages/following/following_history_card.dart +++ b/lib/pages/following/following_history_card.dart @@ -158,7 +158,7 @@ class FollowingHistoryCard extends StatelessWidget { minHeight: AppDimens.followingHistoryProgressHeight, backgroundColor: AppColors.inactiveTrack, valueColor: const AlwaysStoppedAnimation( - AppColors.primary, + AppColors.brandRed, ), ), ), diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 26d7993..b467784 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -17,28 +17,31 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../discover/search_page.dart'; import '../player/open_player.dart'; +import '../recommend/widgets/search_gift_glyphs.dart'; import 'following_edit_bar.dart'; import 'following_favorites_grid.dart'; import 'following_history_card.dart'; import 'following_keep_alive_tab.dart'; import 'following_paged_grid.dart'; -const double _doneFontSize = 14; -const double _doneTextGap = 12; - class FollowingPage extends StatefulWidget { const FollowingPage({ super.key, required this.onGoHome, required this.favoriteController, this.historyRequest = 0, + this.visible = true, }); final VoidCallback onGoHome; final FavoriteController favoriteController; final int historyRequest; + /// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。 + final bool visible; + @override State createState() => _FollowingPageState(); } @@ -102,6 +105,9 @@ class _FollowingPageState extends State { @override void didUpdateWidget(covariant FollowingPage oldWidget) { super.didUpdateWidget(oldWidget); + if (!oldWidget.visible && widget.visible) { + unawaited(_history.load()); + } if (oldWidget.historyRequest == widget.historyRequest) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _selectTab(FollowingTab.history); @@ -111,9 +117,21 @@ class _FollowingPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return ColoredBox( + return DecoratedBox( key: const Key('following-page'), - color: AppColors.appBackground, + // 纯黑底 + 顶部暗红光晕,与设计稿一致。 + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( bottom: false, child: Column( @@ -128,6 +146,7 @@ class _FollowingPageState extends State { FollowingKeepAliveTab( child: FollowingFavoritesGrid( controller: widget.favoriteController, + history: _history, selection: FollowingFavoriteSelection( editing: _editing, selected: _selected, @@ -135,6 +154,9 @@ class _FollowingPageState extends State { ), scrollController: _favoriteScroll, onGoHome: widget.onGoHome, + onContinueRecord: (record) => unawaited( + _openHistory(context, _history, record), + ), ), ), FollowingKeepAliveTab( @@ -165,119 +187,165 @@ class _FollowingPageState extends State { } Widget _header(AppLocalizations l10n) { - return SizedBox( + return Padding( key: const Key('following-header'), - height: AppDimens.followingHeaderHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: ValueListenableBuilder( - valueListenable: _tab, - builder: (context, selectedTab, _) { - return ValueListenableBuilder( - valueListenable: _editing, - builder: (context, editing, _) { + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + AppDimens.followingTitleTopGap, + AppDimens.feedPageHorizontal, + 0, + ), + child: Column( + children: [ + SizedBox( + height: AppDimens.followingTitleRowHeight, + child: Row( + children: [ + Expanded( + child: Text( + l10n.followingTab, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingTitleFont, + fontWeight: FontWeight.w400, + height: 1.0, + ), + ), + ), + _searchButton(l10n), + const SizedBox(width: AppDimens.followingHeaderActionGap), + _manageButton(l10n), + ], + ), + ), + SizedBox( + height: AppDimens.followingSegmentRowHeight, + child: ValueListenableBuilder( + valueListenable: _tab, + builder: (context, selectedTab, _) { return Row( children: [ - Expanded( - child: Row( - children: [ - Flexible( - fit: FlexFit.loose, - child: _tabButton( - FollowingTab.favorites, - l10n.followingTab, - 'favorites', - selectedTab == FollowingTab.favorites, - ), + ListenableBuilder( + listenable: widget.favoriteController, + builder: (context, _) { + return _segment( + FollowingTab.favorites, + Text( + '${l10n.myFavorites} ${widget.favoriteController.total}', + maxLines: 1, ), - const SizedBox(width: AppDimens.followingTabGap), - Flexible( - fit: FlexFit.loose, - child: _tabButton( - FollowingTab.history, - l10n.watchHistory, - 'history', - selectedTab == FollowingTab.history, - ), - ), - ], - ), + 'favorites', + selectedTab == FollowingTab.favorites, + ); + }, + ), + const SizedBox(width: AppDimens.followingSegmentGap), + _segment( + FollowingTab.history, + Text(l10n.watchHistory, maxLines: 1), + 'history', + selectedTab == FollowingTab.history, ), - if (editing) - InkWell( - key: const Key('following-edit'), - onTap: _toggleEditing, - child: Padding( - padding: const EdgeInsets.only(left: _doneTextGap), - child: Text( - l10n.done, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _doneFontSize, - fontWeight: FontWeight.w500, - ), - ), - ), - ) - else - Semantics( - button: true, - label: l10n.edit, - child: SizedBox( - width: AppDimens.followingEditHitSize, - height: AppDimens.followingEditHitSize, - child: InkWell( - key: const Key('following-edit'), - onTap: _toggleEditing, - child: const Align( - alignment: Alignment.centerRight, - child: Icon( - Icons.edit_outlined, - size: AppDimens.followingEditIconSize, - color: AppColors.textTabInactive, - ), - ), - ), - ), - ), ], ); }, - ); - }, + ), + ), + ], + ), + ); + } + + Widget _searchButton(AppLocalizations l10n) { + return Semantics( + button: true, + label: l10n.search, + child: InkWell( + key: const Key('following-search'), + borderRadius: BorderRadius.circular(8), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SearchPage()), + ); + }, + child: Padding( + padding: EdgeInsets.all(4), + child: SearchGlyph( + size: AppDimens.followingSearchIcon, + color: AppColors.textPrimary, + ), ), ), ); } - Widget _tabButton( + Widget _manageButton(AppLocalizations l10n) { + return ValueListenableBuilder( + valueListenable: _editing, + builder: (context, editing, _) { + return InkWell( + key: const Key('following-edit'), + borderRadius: BorderRadius.circular(8), + onTap: _toggleEditing, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8), + child: Text( + editing ? l10n.done : l10n.manage, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingManageFont, + fontWeight: FontWeight.w400, + height: 1.0, + ), + ), + ), + ); + }, + ); + } + + Widget _segment( FollowingTab tab, - String label, + Widget label, String keyName, bool selected, ) { return InkWell( key: Key('following-tab-$keyName'), onTap: () => _selectTab(tab), - child: SizedBox( - height: AppDimens.followingHeaderHeight, - child: Align( - alignment: Alignment.centerLeft, - widthFactor: 1, - child: AnimatedDefaultTextStyle( + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedDefaultTextStyle( duration: AppDimens.followingTabAnimationDuration, style: TextStyle( color: selected ? AppColors.textPrimary : AppColors.textTabInactive, - fontSize: AppDimens.followingTabFont, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + fontSize: AppDimens.followingSegmentFont, + fontWeight: FontWeight.w400, ), - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + child: label, ), - ), + const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap), + AnimatedContainer( + duration: AppDimens.followingTabAnimationDuration, + curve: Curves.easeOut, + width: selected + ? AppDimens.followingSegmentIndicatorWidth + : 0, + height: AppDimens.followingSegmentIndicatorHeight, + decoration: BoxDecoration( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular( + AppDimens.followingSegmentIndicatorRadius, + ), + ), + ), + ], ), ); } @@ -329,10 +397,7 @@ class _FollowingPageState extends State { Future _cancelSelected() async { final ids = _selected.value.toList(); - if (ids.isEmpty || !mounted || !await confirmCancelFollowing(context)) { - return; - } - if (!mounted) return; + if (ids.isEmpty || !mounted) return; final removed = await widget.favoriteController.removeMany(ids); if (!removed || !mounted) return; _selected.value = {}; @@ -424,8 +489,7 @@ class _HistoryList extends StatelessWidget { onTap: openRecord, onContinue: openRecord, onRemove: () async { - if (await confirmHistoryRemove(context) && - await controller.remove(record.dramaId) && + if (await controller.remove(record.dramaId) && controller.records.isEmpty) { onListEmptied(); } diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart index 5ea8167..95ca40f 100644 --- a/lib/pages/following/following_paged_grid.dart +++ b/lib/pages/following/following_paged_grid.dart @@ -28,6 +28,7 @@ class FollowingPagedGrid extends StatelessWidget { this.singleColumn = false, this.errorMessage, this.loadMoreError, + this.topSlivers = const [], }); static const double _loadMoreThreshold = 240; @@ -44,6 +45,10 @@ class FollowingPagedGrid extends StatelessWidget { final String? loadMoreError; final bool showInitialLoading; final bool singleColumn; + + /// 列表顶部的非分页内容(继续观看横幅、区块标题等)。 + /// 必须是 sliver;空列表时也会保留,因此可携带空态上方的固定区块。 + final List topSlivers; final Future Function() onRefresh; final Future Function() onLoadMore; @@ -81,6 +86,7 @@ class FollowingPagedGrid extends StatelessWidget { controller: scrollController, physics: AppPullToRefresh.physics, slivers: [ + ...topSlivers, if (itemCount == 0) SliverFillRemaining( hasScrollBody: false, diff --git a/lib/pages/following/widgets/continue_watching_banner.dart b/lib/pages/following/widgets/continue_watching_banner.dart new file mode 100644 index 0000000..22461e2 --- /dev/null +++ b/lib/pages/following/widgets/continue_watching_banner.dart @@ -0,0 +1,207 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/watch_record.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 追剧页「继续观看」大横幅:取最近一条观看记录,点击续播。 +class ContinueWatchingBanner extends StatelessWidget { + const ContinueWatchingBanner({ + super.key, + required this.record, + required this.languageCode, + required this.onTap, + }); + + final WatchRecord record; + final String languageCode; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final episode = record.episodeNumber; + final total = + record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0; + final title = record.localizedDisplayTitle(languageCode); + final showMeta = episode > 0 && total > 0; + + return SizedBox( + height: AppDimens.followingBannerHeight, + child: ClipRRect( + borderRadius: + BorderRadius.circular(AppDimens.followingBannerRadius), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + record.displayCover, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0xCC000000), + Color(0x99000000), + Color(0x00000000), + ], + stops: [0, 0.45, 1], + ), + ), + ), + Material( + type: MaterialType.transparency, + child: InkWell( + key: const Key('following-continue-banner'), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all( + AppDimens.followingBannerInnerGap, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.bannerTitleWarm, + fontSize: + AppDimens.followingBannerTitleFont, + fontWeight: FontWeight.w400, + ), + ), + if (showMeta) ...[ + const SizedBox( + height: AppDimens.followingBannerMetaGap, + ), + Text( + l10n.continueWatchingSubtitle(episode, total), + maxLines: 1, + style: const TextStyle( + color: Color(0xB3F5EFE4), + fontSize: + AppDimens.followingBannerSubtitleFont, + ), + ), + ], + const SizedBox( + height: AppDimens.followingBannerButtonTopGap, + ), + _ContinueButton(label: l10n.continueWatching), + ], + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: _ProgressBar(fraction: record.progress.fraction), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ContinueButton extends StatelessWidget { + const _ContinueButton({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular( + AppDimens.followingBannerButtonRadius, + ), + ), + child: SizedBox( + height: AppDimens.followingBannerButtonHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: + AppDimens.followingBannerButtonHorizontalPadding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + size: AppDimens.followingBannerButtonIcon, + color: AppColors.onPrimary, + ), + const SizedBox(width: AppDimens.followingBannerButtonGap), + Text( + label, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: AppDimens.followingBannerButtonFont, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ProgressBar extends StatelessWidget { + const _ProgressBar({required this.fraction}); + + final double fraction; + + @override + Widget build(BuildContext context) { + // SizedBox 给紧约束让 DecoratedBox 轨道撑满整宽; + // 不能用无子 ColoredBox 直接放 Stack(会缩成 0×0 隐形)。 + return ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.followingBannerProgressHeight, + ), + child: SizedBox( + height: AppDimens.followingBannerProgressHeight, + width: double.infinity, + child: DecoratedBox( + decoration: const BoxDecoration( + color: Color(0x66FFFFFF), + ), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: fraction.clamp(0.0, 1.0).toDouble(), + // 无 child 的 ColoredBox 在松约束下会缩成 0×0,高度必须显式占满。 + heightFactor: 1.0, + child: const ColoredBox(color: AppColors.brandRed), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 66e2ce3..b6dc824 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -143,7 +143,7 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _scaffold(BuildContext context) { - final mediaTab = logic.tab == 0 || logic.tab == 1; + // 各 tab 均自绘背景(媒体 tab 纯黑,追剧/我的带顶部红光晕),无需外层渐变。 final content = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -160,13 +160,7 @@ class _HomeTabsState extends State<_HomeTabs> { ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, - child: mediaTab - ? content - : DecoratedBox( - key: const Key('home-page-gradient'), - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), - child: content, - ), + child: content, ); } @@ -178,6 +172,7 @@ class _HomeTabsState extends State<_HomeTabs> { onGoHome: () => logic.select(0), favoriteController: context.read(), historyRequest: logic.followingHistoryRequest, + visible: logic.tab == 2, ), _my(context), ]; diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 18ab58d..2e616b4 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -6,39 +6,33 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/drama.dart'; -import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; import '../account/account_profile_page.dart'; +import '../favorites/favorites_page.dart'; import '../filter/filter_page.dart'; import '../notices/notices_page.dart'; -import '../player/open_player.dart'; import '../settings/settings_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; -import 'about_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; import 'mine_types.dart'; -import 'widgets/mine_continue_watching.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; -import 'widgets/mine_more_services.dart'; -import 'widgets/mine_stats_panel.dart'; /// K28-B 我的页。 class MyPage extends StatefulWidget { @@ -136,41 +130,27 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final content = DecoratedBox( key: const Key('mine-page-surface'), + // 与追剧页同款:纯黑底 + 顶部暗红光晕。 decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - AppColors.mineGradientTop, - AppColors.mineGradientMiddle, - AppColors.mineGradientBottom, + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, ], - stops: [0, 0.4, 0.75], + stops: [0, 0.14, 0.3], ), ), - child: Stack( - fit: StackFit.expand, - children: [ - const DecoratedBox( - key: Key('mine-page-glow'), - decoration: BoxDecoration( - gradient: RadialGradient( - center: Alignment(0, -0.95), - radius: 0.9, - colors: [AppColors.mineGradientGlow, AppColors.transparent], - ), - ), - ), - SafeArea( - bottom: false, - child: Column( - children: [ - Expanded(child: _body(context)), - if (!embedded) _bottomNav(), - ], - ), - ), - ], + child: SafeArea( + bottom: false, + child: Column( + children: [ + Expanded(child: _body(context)), + if (!embedded) _bottomNav(), + ], + ), ), ); return AnnotatedRegion( @@ -185,22 +165,7 @@ class _MyPageState extends State { } Widget _body(BuildContext context) { - final auth = context.watch(); - final favorites = context.watch(); - final wallet = context.watch(); - final continueWatching = logic.recent.take(10).toList(growable: false); - final followingCount = - favorites.error == null && !(favorites.loading && favorites.total == 0) - ? favorites.total - : null; - final watchRecordCount = - logic.recentError == null && - !(logic.recentLoading && logic.watchRecordTotal == null) - ? logic.watchRecordTotal - : null; - final coinBalance = auth.isSignedIn && wallet.snapshot != null - ? wallet.balance - : null; + final l10n = AppLocalizations.of(context)!; return ListView( key: const Key('mine-page-scroll'), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), @@ -214,6 +179,7 @@ class _MyPageState extends State { onEdit: () => _push(const AccountProfilePage()), ), ), + const SizedBox(height: AppDimens.spaceMd), Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.pageHorizontal, @@ -222,23 +188,16 @@ class _MyPageState extends State { onOpen: () => _openDestination(MineDestination.vip), ), ), - if (continueWatching.isNotEmpty) ...[ - const SizedBox(height: AppDimens.mineSectionGap), - MineContinueWatching( - records: continueWatching, - onOpenRecord: _openContinueRecord, - onOpenAll: _openHistoryTab, - ), - ], const SizedBox(height: AppDimens.mineSectionGap), Padding( padding: const EdgeInsets.symmetric( - horizontal: AppDimens.mineGridHorizontal, + horizontal: AppDimens.pageHorizontal, ), - child: MineStatsPanel( - followingCount: followingCount, - watchRecordCount: watchRecordCount, - coinBalance: coinBalance, + child: MineQuickActions( + onWallet: () => _openDestination(MineDestination.wallet), + onFavorites: () => _push(const FavoritesPage()), + onDownloads: () => _openDestination(MineDestination.downloads), + onHistory: () => _openDestination(MineDestination.history), ), ), const SizedBox(height: AppDimens.mineSectionGap), @@ -246,25 +205,40 @@ class _MyPageState extends State { padding: const EdgeInsets.symmetric( horizontal: AppDimens.mineGridHorizontal, ), - child: MineMenuList( - onOpen: _openDestination, - onLanguage: _openLanguage, - ), - ), - const SizedBox(height: AppDimens.mineSectionGap), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.mineGridHorizontal, - ), - child: MineMoreServices( - onCommonQuestions: _pushPlaceholder, - onAbout: () => _push(const MineAboutPage()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.moreServices, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineSectionTitle, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + MineServiceList( + onOpen: _openDestination, + onLanguage: _openLanguage, + languageName: _languageName(context), + ), + ], ), ), ], ); } + /// 语言设置行右侧展示的当前语言名。 + String _languageName(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final code = context.read().locale?.languageCode; + return switch (code) { + 'en' => l10n.english, + 'id' => l10n.bahasaIndonesia, + _ => l10n.simplifiedChinese, + }; + } + void _openDestination(MineDestination destination) { switch (destination) { case MineDestination.history: @@ -290,45 +264,6 @@ class _MyPageState extends State { unawaited(openAuthPage(context)); } - void _openHistoryTab() { - final callback = widget.callbacks.onOpenHistoryTab; - if (callback != null) { - callback(); - return; - } - unawaited(_openHistoryPage()); - } - - /// 继续观看卡片:补齐剧集后直接进全屏播放页,播放器自己会定位到记录的秒数。 - Future _openContinueRecord(WatchProgress record) async { - final catalog = context.read(); - // 续看记录只有剧的概要信息,先拼一个占位对象让目录去取完整详情 - final summary = Drama( - id: record.dramaId, - title: record.dramaTitle ?? '', - synopsis: '', - posterUrl: record.dramaCover ?? '', - ); - Drama drama; - try { - drama = await catalog.requireDetail(summary); - } catch (_) { - if (!mounted) return; - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed))); - return; - } - if (!mounted) return; - await playDramaFromCatalog( - context, - drama, - episodeId: record.episodeId.isEmpty ? null : record.episodeId, - ); - if (mounted) unawaited(logic.refreshRecent()); - } - Future _openHistoryPage() async { await _push(const WatchRecordsPage()); if (mounted) unawaited(logic.refreshRecent()); @@ -341,7 +276,25 @@ class _MyPageState extends State { } void _pushPlaceholder() { - unawaited(_push(const _MinePlaceholderPage())); + final l10n = AppLocalizations.of(context)!; + unawaited( + _push( + PlainPageScaffold( + pageKey: const Key('mine-placeholder-page'), + backKey: const Key('mine-placeholder-back'), + title: l10n.contactSupport, + child: Center( + child: Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.body, + ), + ), + ), + ), + ), + ); } Future _openProtected(Widget page) async { @@ -410,14 +363,16 @@ class _LanguageSheet extends StatelessWidget { (const Locale('en'), const Key('mine-language-en'), l10n.english), (const Locale('id'), const Key('mine-language-id'), l10n.bahasaIndonesia), ]; - return Material( - key: const Key('mine-language-sheet'), - color: AppColors.contentCard, + return AppGlowPanel( borderRadius: const BorderRadius.vertical( top: Radius.circular(AppDimens.mineCardRadius), ), - clipBehavior: Clip.antiAlias, - child: SafeArea( + border: false, + child: Material( + key: const Key('mine-language-sheet'), + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), @@ -440,7 +395,13 @@ class _LanguageSheet extends StatelessWidget { IconButton( tooltip: l10n.close, onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close_rounded), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + icon: const Icon(Icons.close_rounded, size: 20), ), ], ), @@ -461,29 +422,6 @@ class _LanguageSheet extends StatelessWidget { ), ), ), - ); - } -} - -class _MinePlaceholderPage extends StatelessWidget { - const _MinePlaceholderPage(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final title = l10n.helpFeedback; - return PlainPageScaffold( - pageKey: const Key('mine-placeholder-page'), - backKey: const Key('mine-placeholder-back'), - title: title, - child: Center( - child: Text( - l10n.comingSoon, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.body, - ), - ), ), ); } diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index 94bb6f3..890ead8 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,8 +14,7 @@ import '../../../core/widgets/centered_toast.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -const double _buttonHeight = 34; -const double _buttonRadius = 6; +const double _buttonHeight = 40; /// 「我的」页用户区。 class MineHeader extends StatelessWidget { @@ -35,7 +35,7 @@ class MineHeader extends StatelessWidget { final uid = signedIn ? user?.uid.trim() ?? '' : ''; return Padding( key: const Key('mine-user-row'), - padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd), + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -55,7 +55,7 @@ class MineHeader extends StatelessWidget { fontFamily: 'Outfit', color: AppColors.textPrimary, fontSize: AppDimens.mineUserName, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, height: AppDimens.mineUserNameLineHeight, ), ), @@ -121,18 +121,29 @@ class MineHeader extends StatelessWidget { key: const Key('mine-avatar'), width: AppDimens.mineAvatar, height: AppDimens.mineAvatar, - decoration: const BoxDecoration( - color: AppColors.contentSurface, + decoration: BoxDecoration( shape: BoxShape.circle, + border: Border.all( + color: AppColors.textTertiary, + width: 1.2, + ), + ), + child: ClipOval( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + color: AppColors.textPrimary.withValues(alpha: 0.06), + alignment: Alignment.center, + child: path.isEmpty + ? const Icon( + Icons.person_outline_rounded, + color: AppColors.textPrimary, + size: 30, + ) + : RemoteImage(path, fit: BoxFit.cover), + ), + ), ), - clipBehavior: Clip.antiAlias, - child: path.isEmpty - ? const Icon( - Icons.person_rounded, - color: AppColors.textPrimary, - size: 28, - ) - : RemoteImage(path, fit: BoxFit.cover), ); } @@ -141,20 +152,20 @@ class MineHeader extends StatelessWidget { required String label, required VoidCallback onTap, }) { - return OutlinedButton( + return TextButton( key: key, onPressed: onTap, - style: OutlinedButton.styleFrom( + style: TextButton.styleFrom( minimumSize: const Size(0, _buttonHeight), padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), - foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.mineOutline), + backgroundColor: AppColors.brandRed, + foregroundColor: AppColors.onPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_buttonRadius), + borderRadius: BorderRadius.circular(_buttonHeight / 2), ), textStyle: const TextStyle( fontSize: AppDimens.body, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, ), ), child: Row( diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 220b073..e2da934 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -7,11 +7,9 @@ import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import '../mine_types.dart'; -const double _vipRadius = 12; -const double _vipBadgeSize = 36; -const double _vipButtonHeight = 34; +const double _vipRadius = 14; -/// 金色会员横幅。 +/// 「我的」页金色 VIP 卡:品牌行 + 立即开通 + 2×2 权益宫格。 class MineVipBanner extends StatelessWidget { const MineVipBanner({super.key, required this.onOpen}); @@ -24,7 +22,9 @@ class MineVipBanner extends StatelessWidget { key: const Key('mine-vip'), decoration: BoxDecoration( gradient: const LinearGradient( - colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.vipCardStart, AppColors.vipCardEnd], ), borderRadius: BorderRadius.circular(_vipRadius), ), @@ -35,69 +35,89 @@ class MineVipBanner extends StatelessWidget { child: InkWell( onTap: onOpen, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.cardPadding, - vertical: AppDimens.spaceMd, + padding: const EdgeInsets.fromLTRB( + AppDimens.cardPadding, + AppDimens.spaceSm + 2, + AppDimens.cardPadding, + AppDimens.spaceSm + 2, ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: _vipBadgeSize, - height: _vipBadgeSize, - decoration: const BoxDecoration( - color: AppColors.vipBadgeSurface, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.workspace_premium_rounded, - color: AppColors.onGold, - size: 22, - ), - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( + Row( + children: [ + const Icon( + Icons.diamond_outlined, + color: AppColors.onGold, + size: 24, + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( l10n.vipBrandName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onGold, - fontSize: AppDimens.sectionTitle, - fontWeight: FontWeight.w700, + fontSize: 18, + fontWeight: FontWeight.w400, ), ), - const SizedBox(height: 2), - Text( - l10n.mineVipSubtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onGold, - fontSize: AppDimens.caption, - ), - ), - ], + ), + const SizedBox(width: AppDimens.spaceSm), + _openButton(l10n), + ], + ), + Transform.translate( + offset: const Offset(0, -7), + child: Text( + l10n.mineVipSubtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onGold.withValues(alpha: 0.7), + fontSize: AppDimens.caption, + ), ), ), - const SizedBox(width: AppDimens.spaceSm), - TextButton( - key: const Key('mine-vip-open'), - onPressed: onOpen, - style: TextButton.styleFrom( - minimumSize: const Size(0, _vipButtonHeight), - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.spaceMd, + const SizedBox(height: AppDimens.mineVipBenefitRowGap), + Container( + height: 1, + color: AppColors.onGold.withValues(alpha: 0.15), + ), + const SizedBox(height: AppDimens.mineVipBenefitRowGap), + Table( + defaultColumnWidth: const FlexColumnWidth(), + children: [ + TableRow( + children: [ + _benefit( + key: const Key('mine-vip-benefit-0'), + icon: Icons.play_circle_outline_rounded, + label: l10n.vipBenefitFree, + ), + _benefit( + key: const Key('mine-vip-benefit-1'), + icon: Icons.ads_click_rounded, + label: l10n.vipBenefitNoAds, + ), + ], ), - backgroundColor: AppColors.onGold, - foregroundColor: AppColors.textPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_vipButtonHeight), + TableRow( + children: [ + _benefit( + key: const Key('mine-vip-benefit-2'), + icon: Icons.hd_outlined, + label: l10n.vipBenefitHd, + ), + _benefit( + key: const Key('mine-vip-benefit-3'), + icon: Icons.download_for_offline_outlined, + label: l10n.vipBenefitFastCache, + ), + ], ), - ), - child: Text(l10n.joinVip), + ], ), ], ), @@ -106,184 +126,256 @@ class MineVipBanner extends StatelessWidget { ), ); } + + Widget _openButton(AppLocalizations l10n) { + return TextButton( + key: const Key('mine-vip-open'), + onPressed: onOpen, + style: TextButton.styleFrom( + minimumSize: const Size(0, AppDimens.mineVipButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + backgroundColor: AppColors.onGold, + foregroundColor: AppColors.vipGoldStart, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.mineVipButtonHeight / 2, + ), + ), + textStyle: const TextStyle( + fontSize: AppDimens.body, + fontWeight: FontWeight.w400, + ), + ), + child: Text(l10n.vipOpenNow), + ); + } + + Widget _benefit({required Key key, required IconData icon, required String label}) { + return Padding( + key: key, + padding: const EdgeInsets.symmetric(vertical: AppDimens.mineVipBenefitRowGap), + child: Row( + children: [ + Container( + width: AppDimens.mineVipBenefitBox, + height: AppDimens.mineVipBenefitBox, + decoration: BoxDecoration( + border: Border.all( + color: AppColors.onGold.withValues(alpha: 0.6), + width: 1.2, + ), + borderRadius: BorderRadius.circular( + AppDimens.mineVipBenefitBoxRadius, + ), + ), + child: Icon( + icon, + color: AppColors.onGold, + size: AppDimens.mineVipBenefitGlyph, + ), + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onGold, + fontSize: AppDimens.mineVipBenefitFont, + ), + ), + ), + ], + ), + ); + } } -/// 「我的」页 4 列功能入口。 -class MineMenuList extends StatelessWidget { - const MineMenuList({ +/// 快捷图标行:钱包 / 我的收藏 / 离线缓存 / 播放历史(设计稿的「奖励」暂不做)。 +class MineQuickActions extends StatelessWidget { + const MineQuickActions({ super.key, - required this.onOpen, - required this.onLanguage, + required this.onWallet, + required this.onFavorites, + required this.onDownloads, + required this.onHistory, }); - final ValueChanged onOpen; - final VoidCallback onLanguage; + final VoidCallback onWallet; + final VoidCallback onFavorites; + final VoidCallback onDownloads; + final VoidCallback onHistory; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Container( - key: const Key('mine-menu'), - decoration: BoxDecoration( - color: AppColors.mineGridSurface, - borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), - ), - child: Material( - color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), - clipBehavior: Clip.antiAlias, - child: Table( - key: const Key('mine-menu-grid'), - defaultColumnWidth: const FlexColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - border: const TableBorder( - horizontalInside: BorderSide( - color: AppColors.mineGridDivider, - width: AppDimens.mineGridDividerWidth, - ), - verticalInside: BorderSide( - color: AppColors.mineGridDivider, - width: AppDimens.mineGridDividerWidth, - ), + return Row( + children: [ + _action( + key: const Key('mine-wallet'), + icon: Icons.wallet_outlined, + label: l10n.wallet, + onTap: onWallet, + ), + _action( + key: const Key('mine-favorites'), + icon: Icons.star_border_rounded, + label: l10n.myFavorites, + onTap: onFavorites, + ), + _action( + key: const Key('mine-downloads'), + icon: Icons.download_outlined, + label: l10n.offlineDownloads, + onTap: onDownloads, + ), + _action( + key: const Key('mine-history'), + icon: Icons.history_rounded, + label: l10n.watchHistory, + onTap: onHistory, + ), + ], + ); + } + + Widget _action({ + required Key key, + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return Expanded( + child: InkWell( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineQuickActionIcon), + const SizedBox(height: AppDimens.mineQuickActionGap), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineQuickActionLabelFont, + ), + ), + ], ), + ), + ), + ); + } +} + +/// 「更多服务」列表行:白图标 + 标题 + 可选值 + chevron,行间细分隔线。 +class MineServiceList extends StatelessWidget { + const MineServiceList({ + super.key, + required this.onOpen, + required this.onLanguage, + required this.languageName, + }); + + final ValueChanged onOpen; + final VoidCallback onLanguage; + final String languageName; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final rows = [ + _row( + key: const Key('mine-help'), + icon: Icons.headset_outlined, + label: l10n.contactSupport, + onTap: () => onOpen(MineDestination.help), + ), + _divider(), + _row( + key: const Key('mine-language'), + icon: Icons.language_rounded, + label: l10n.language, + value: languageName, + onTap: onLanguage, + ), + _divider(), + _row( + key: const Key('mine-settings'), + icon: Icons.settings_outlined, + label: l10n.settings, + onTap: () => onOpen(MineDestination.settings), + ), + _divider(), + _row( + key: const Key('mine-notices'), + icon: Icons.notifications_none_rounded, + label: l10n.notices, + onTap: () => onOpen(MineDestination.notices), + ), + ]; + return Column(key: const Key('mine-menu'), children: rows); + } + + Widget _divider() => const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.contentBorder, + ); + + Widget _row({ + required Key key, + required IconData icon, + required String label, + String? value, + required VoidCallback onTap, + }) { + return SizedBox( + height: AppDimens.mineServiceRowHeight, + child: InkWell( + key: key, + onTap: onTap, + child: Row( children: [ - TableRow( - children: [ - _cell( - index: 0, - actionKey: const Key('mine-wallet'), - icon: Icons.account_balance_wallet_outlined, - iconColor: AppColors.mineIconWallet, - label: l10n.wallet, - onTap: () => onOpen(MineDestination.wallet), + Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineServiceIcon), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineServiceTitleFont, ), - _cell( - index: 1, - actionKey: const Key('mine-downloads'), - icon: Icons.download_outlined, - iconColor: AppColors.mineIconDownloads, - label: l10n.offlineDownloads, - onTap: () => onOpen(MineDestination.downloads), - ), - _cell( - index: 2, - actionKey: const Key('mine-history'), - icon: Icons.history_rounded, - iconColor: AppColors.mineIconHistory, - label: l10n.watchHistory, - onTap: () => onOpen(MineDestination.history), - ), - _cell( - index: 3, - actionKey: const Key('mine-watch-preferences'), - icon: Icons.tune_rounded, - iconColor: AppColors.mineIconPreferences, - label: l10n.watchPreferences, - onTap: () => onOpen(MineDestination.preferences), - ), - ], + ), ), - TableRow( - children: [ - _cell( - index: 4, - actionKey: const Key('mine-help'), - icon: Icons.support_agent_rounded, - iconColor: AppColors.mineIconSupport, - label: l10n.contactSupport, - onTap: () => onOpen(MineDestination.help), + if (value != null && value.isNotEmpty) ...[ + const SizedBox(width: AppDimens.spaceSm), + Text( + value, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineServiceValueFont, ), - _cell( - index: 5, - actionKey: const Key('mine-language'), - icon: Icons.language_rounded, - iconColor: AppColors.mineIconLanguage, - label: l10n.language, - onTap: onLanguage, - ), - _cell( - index: 6, - actionKey: const Key('mine-settings'), - icon: Icons.settings_outlined, - iconColor: AppColors.mineIconSettings, - label: l10n.settings, - onTap: () => onOpen(MineDestination.settings), - ), - _cell( - index: 7, - actionKey: const Key('mine-notices'), - icon: Icons.notifications_none_rounded, - iconColor: AppColors.mineIconNotices, - label: l10n.notices, - onTap: () => onOpen(MineDestination.notices), - ), - ], + ), + ], + const SizedBox(width: AppDimens.spaceXs), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 22, ), ], ), ), ); } - - Widget _cell({ - required int index, - Key? actionKey, - IconData? icon, - Color? iconColor, - String? label, - VoidCallback? onTap, - }) { - return SizedBox( - key: Key('mine-menu-cell-$index'), - height: AppDimens.mineGridCellHeight, - child: actionKey == null - ? const SizedBox.shrink() - : InkWell( - key: actionKey, - onTap: onTap, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: AppDimens.mineGridIconSurface, - height: AppDimens.mineGridIconSurface, - decoration: const BoxDecoration( - color: AppColors.mineGridIconSurface, - shape: BoxShape.circle, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: iconColor?.withValues(alpha: 0.08), - shape: BoxShape.circle, - ), - child: Icon( - icon, - color: iconColor, - size: AppDimens.mineGridIcon, - ), - ), - ), - const SizedBox(height: AppDimens.spaceXs + 2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - label ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.mineGridLabel, - fontSize: AppDimens.mineGridLabel, - fontWeight: FontWeight.w500, - height: 1.2, - ), - ), - ), - ], - ), - ), - ), - ); - } } diff --git a/lib/pages/mine/widgets/mine_more_services.dart b/lib/pages/mine/widgets/mine_more_services.dart deleted file mode 100644 index 2cd0130..0000000 --- a/lib/pages/mine/widgets/mine_more_services.dart +++ /dev/null @@ -1,93 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../l10n/app_localizations.dart'; - -class MineMoreServices extends StatelessWidget { - const MineMoreServices({ - super.key, - required this.onCommonQuestions, - required this.onAbout, - }); - - final VoidCallback onCommonQuestions; - final VoidCallback onAbout; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Container( - key: const Key('mine-more-services'), - decoration: BoxDecoration( - color: AppColors.minePanelSurface, - borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), - ), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - _row( - key: const Key('mine-common-questions'), - label: l10n.commonQuestions, - onTap: onCommonQuestions, - ), - const Divider( - height: AppDimens.mineGridDividerWidth, - thickness: AppDimens.mineGridDividerWidth, - color: AppColors.mineMoreDivider, - ), - _row( - key: const Key('mine-about'), - label: l10n.aboutUs, - onTap: onAbout, - ), - ], - ), - ); - } - - Widget _row({ - required Key key, - required String label, - required VoidCallback onTap, - }) { - return Material( - color: AppColors.transparent, - child: InkWell( - key: key, - onTap: onTap, - child: SizedBox( - height: AppDimens.mineMoreRowHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.minePanelPadding, - ), - child: Row( - children: [ - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.mineMoreFont, - fontWeight: FontWeight.w400, - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 18, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 37aa204..7c0e565 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -10,7 +10,6 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/remote_image.dart'; @@ -108,7 +107,18 @@ class _NewReleasesPageState extends State { key: const Key('new-releases-page'), backgroundColor: AppColors.ink, body: DecoratedBox( - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 93c98ec..8d7c144 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -358,40 +358,22 @@ class _RecommendPageState extends State begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - AppColors.mineGradientTop, - AppColors.mineGradientMiddle, - AppColors.mineGradientBottom, + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, ], - stops: [0, 0.4, 0.75], + stops: [0, 0.14, 0.3], ), ), - child: Stack( - fit: StackFit.expand, - children: [ - const DecoratedBox( - key: Key('recommend-playlist-glow'), - decoration: BoxDecoration( - gradient: RadialGradient( - center: Alignment(0, -0.95), - radius: 0.9, - colors: [ - AppColors.recommendPlaylistGlow, - AppColors.transparent, - ], - ), - ), - ), - _withTopNavSpace( - RecommendSectionsView( - feedController: widget.feedController, - categories: categories, - onOpenDrama: _openDrama, - onMore: _handleMore, - playlists: _watchPlaylists(context), - onOpenPlaylist: _openPlaylist, - ), - ), - ], + child: _withTopNavSpace( + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + playlists: _watchPlaylists(context), + onOpenPlaylist: _openPlaylist, + ), ), ); } diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 7f43e8b..d3d8d1e 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -20,6 +20,8 @@ import '../../home/widgets/home_feed_card.dart'; const double _loadMoreThreshold = 240; const int _gridColumns = 3; +// 标题两行39 + 上间距6 + 标签间距6 + 标签13 = 64,比 homeCardBodyExtent 更紧凑。 +const double _cardBodyExtent = 64; /// 推荐页「全部短剧」筛选和三列列表。 class AllDramasFilterGrid extends StatefulWidget { @@ -94,7 +96,7 @@ class _AllDramasFilterGridState extends State { final itemWidth = (width - AppDimens.allDramasHorizontal * 2 - AppDimens.allDramasGridGap * 2) / _gridColumns; - return itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; + return itemWidth / AppDimens.cardCoverAspectRatio + _cardBodyExtent; } Widget _dramaList(AppLocalizations l10n, double itemExtent) { @@ -309,7 +311,7 @@ class _AllDramasFilterGridState extends State { ? AppColors.filterChipTextSelected : AppColors.filterChipTextInactive, fontSize: AppDimens.filterChipFont, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + fontWeight: FontWeight.w400, height: 1, ), ), diff --git a/lib/pages/recommend/widgets/playlist_detail_page.dart b/lib/pages/recommend/widgets/playlist_detail_page.dart index d3e04b4..3ac7c37 100644 --- a/lib/pages/recommend/widgets/playlist_detail_page.dart +++ b/lib/pages/recommend/widgets/playlist_detail_page.dart @@ -6,7 +6,6 @@ import '../../../core/data/models/drama.dart'; import '../../../core/data/models/playlist_detail.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../core/widgets/remote_image.dart'; @@ -19,6 +18,8 @@ const int _collageCoverCount = 5; const double _loadMoreThreshold = 200; const double _pageHorizontal = 14; const double _gridGap = 10; +// 标题两行39 + 上间距6 + 标签间距6 + 标签13 = 64,比 homeCardBodyExtent 更紧凑。 +const double _cardBodyExtent = 64; const double _sprocketBand = 22; const Color _filmBody = Color(0xFF161311); const Color _filmHole = Color(0xFFE6D3BE); @@ -111,13 +112,23 @@ class _PlaylistDetailPageState extends State { Widget build(BuildContext context) { final width = MediaQuery.sizeOf(context).width; final itemWidth = (width - _pageHorizontal * 2 - _gridGap) / 2; - final itemExtent = - itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; + final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + _cardBodyExtent; return Scaffold( key: const Key('playlist-detail-page'), backgroundColor: AppColors.ink, body: DecoratedBox( - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -176,7 +187,7 @@ class _PlaylistDetailPageState extends State { gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: _gridGap, - mainAxisSpacing: 16, + mainAxisSpacing: 10, mainAxisExtent: itemExtent, ), delegate: SliverChildBuilderDelegate( @@ -228,7 +239,7 @@ class _PlaylistDetailPageState extends State { style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.recommendSectionTitle, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, ), ), const SizedBox(height: 2), diff --git a/lib/pages/recommend/widgets/recommend_channel_nav.dart b/lib/pages/recommend/widgets/recommend_channel_nav.dart index 74bbdcc..3d69c7a 100644 --- a/lib/pages/recommend/widgets/recommend_channel_nav.dart +++ b/lib/pages/recommend/widgets/recommend_channel_nav.dart @@ -5,13 +5,14 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; +import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; const double _tabFont = 17; const double _searchHitSize = 40; const double _searchIconSize = 26; -const double _giftIconSize = 30; +const double _giftIconSize = 26; const double _underlineWidth = 26; const double _underlineHeight = 3; @@ -146,7 +147,7 @@ class _RecommendChannelNavState extends State { ), iconSize: _searchIconSize, color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), + icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary), ), ), if (widget.onGift != null) @@ -162,12 +163,7 @@ class _RecommendChannelNavState extends State { height: _searchHitSize, ), iconSize: _giftIconSize, - icon: Image.asset( - 'assets/icon/gift.png', - width: _giftIconSize, - height: _giftIconSize, - fit: BoxFit.contain, - ), + icon: GiftGlyph(size: _giftIconSize), ), ), const SizedBox(width: 8), @@ -200,6 +196,7 @@ class _RecommendChannelNavState extends State { fontSize: _tabFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, + height: 1.0, shadows: const [ Shadow(color: AppColors.mediaBlack, blurRadius: 6), ], diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 69418a9..e64527d 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -92,7 +92,7 @@ class RecommendSectionsView extends StatelessWidget { dramas: hits.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.hits), ), - const SizedBox(height: AppDimens.spaceXl), + const SizedBox(height: AppDimens.recommendSectionGap), _section( context, l10n, @@ -102,7 +102,7 @@ class RecommendSectionsView extends StatelessWidget { dramas: newest.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.newReleases), ), - const SizedBox(height: AppDimens.spaceXl), + const SizedBox(height: AppDimens.recommendSectionGap), _section( context, l10n, @@ -150,7 +150,7 @@ class RecommendSectionsView extends StatelessWidget { final sections = []; for (final playlist in remote.items) { if (sections.isNotEmpty) { - sections.add(const SizedBox(height: AppDimens.spaceXl)); + sections.add(const SizedBox(height: AppDimens.recommendSectionGap)); } sections.add( _section( @@ -238,7 +238,7 @@ class RecommendSectionsView extends StatelessWidget { style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.recommendSectionTitle, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, ), ), const SizedBox(height: 2), @@ -277,7 +277,7 @@ class RecommendSectionsView extends StatelessWidget { ], ), ), - const SizedBox(height: AppDimens.spaceMd), + const SizedBox(height: AppDimens.spaceXs), SizedBox( height: AppDimens.recommendRowHeight, child: ListView.separated( diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 1a85118..96a7af4 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -5,13 +5,14 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../recommend_types.dart'; +import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; const double _tabFont = 17; const double _searchHitSize = 40; const double _searchIconSize = 26; -const double _giftIconSize = 30; +const double _giftIconSize = 26; const double _underlineWidth = 26; const double _underlineHeight = 3; @@ -87,7 +88,7 @@ class RecommendTopNav extends StatelessWidget { ), iconSize: _searchIconSize, color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), + icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary), ), ), if (onGift != null) @@ -103,12 +104,7 @@ class RecommendTopNav extends StatelessWidget { height: _searchHitSize, ), iconSize: _giftIconSize, - icon: Image.asset( - 'assets/icon/gift.png', - width: _giftIconSize, - height: _giftIconSize, - fit: BoxFit.contain, - ), + icon: GiftGlyph(size: _giftIconSize), ), ), const SizedBox(width: 8), @@ -142,6 +138,7 @@ class RecommendTopNav extends StatelessWidget { fontSize: _tabFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, + height: 1.0, shadows: shadows, ), ), diff --git a/lib/pages/recommend/widgets/search_gift_glyphs.dart b/lib/pages/recommend/widgets/search_gift_glyphs.dart new file mode 100644 index 0000000..721953a --- /dev/null +++ b/lib/pages/recommend/widgets/search_gift_glyphs.dart @@ -0,0 +1,78 @@ +// Author: fruit.com.cn + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// 首页右上角搜索/礼物图标的自定义样式: +/// 搜索为粗线条放大镜(Material 图标无法调线条粗细),礼物为 emoji 红金礼盒。 + +/// 粗线条白色放大镜,圆环在左上、手柄朝右下。 +class SearchGlyph extends StatelessWidget { + const SearchGlyph({super.key, required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size.square(size), + painter: _SearchPainter(color: color), + ); + } +} + +class _SearchPainter extends CustomPainter { + const _SearchPainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final stroke = size.width * 0.09; + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + + final circleDiameter = size.width * 0.7; + final direction = math.pi / 4; + // 图形只占画布左上角,先平移让它视觉居中,否则会比文字基线偏高。 + final drawnExtent = + circleDiameter / 2 + + (circleDiameter / 2 + size.width * 0.12) * math.sin(direction) + + stroke / 2; + final centering = (size.width - drawnExtent) / 2; + canvas.translate(centering, centering); + + final center = Offset(circleDiameter / 2, circleDiameter / 2); + canvas.drawCircle(center, circleDiameter / 2 - stroke / 2, paint); + + final start = center + Offset.fromDirection(direction, circleDiameter / 2 - stroke * 0.2); + final end = center + Offset.fromDirection(direction, circleDiameter / 2 + size.width * 0.12); + canvas.drawLine(start, end, paint); + } + + @override + bool shouldRepaint(covariant _SearchPainter oldDelegate) => + oldDelegate.color != color; +} + +/// emoji 礼盒(红盒金丝带,随系统 emoji 风格渲染)。 +class GiftGlyph extends StatelessWidget { + const GiftGlyph({super.key, required this.size}); + + final double size; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: size, + child: FittedBox( + child: Text('🎁', style: TextStyle(fontSize: size, height: 1)), + ), + ); + } +} diff --git a/lib/pages/settings/legal_links_card.dart b/lib/pages/settings/legal_links_card.dart index 020f592..8fd1405 100644 --- a/lib/pages/settings/legal_links_card.dart +++ b/lib/pages/settings/legal_links_card.dart @@ -24,16 +24,17 @@ class LegalLinksCard extends StatelessWidget { key: const Key('settings-terms'), title: l10n.legalTerms, path: legalTermsPath, + icon: Icons.article_outlined, ), _LegalRow( key: const Key('settings-privacy'), title: l10n.legalPrivacy, path: legalPrivacyPath, + icon: Icons.shield_outlined, ), ]; return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + color: AppColors.contentSurface, clipBehavior: Clip.antiAlias, child: Column( children: [ @@ -42,7 +43,7 @@ class LegalLinksCard extends StatelessWidget { const Divider( height: AppDimens.mineGridDividerWidth, thickness: AppDimens.mineGridDividerWidth, - color: AppColors.outlineVariantDark, + color: AppColors.contentBorder, ), _tile(context, rows[index]), ], @@ -61,6 +62,12 @@ class LegalLinksCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), child: Row( children: [ + Icon( + row.icon, + color: AppColors.textSecondary, + size: 20, + ), + const SizedBox(width: 10), Expanded( child: Text( row.title, @@ -96,9 +103,15 @@ class LegalLinksCard extends StatelessWidget { } class _LegalRow { - const _LegalRow({required this.key, required this.title, required this.path}); + const _LegalRow({ + required this.key, + required this.title, + required this.path, + required this.icon, + }); final Key key; final String title; final String path; + final IconData icon; } diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 02c798d..9c9fc59 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -1,17 +1,17 @@ // Author: fruit.com.cn -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../account/account_entry_card.dart'; +import '../mine/about_page.dart'; import 'legal_links_card.dart'; /// 设置页:账号入口按列表展示,不提供通知和手动语言切换。 @@ -31,35 +31,184 @@ class SettingsPage extends StatelessWidget { child: ListView( padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), children: [ + Container( + key: const Key('settings-group'), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + if (auth.isSignedIn) ...[ + const AccountEntryCard(), + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.contentBorder, + ), + ], + const LegalLinksCard(), + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.contentBorder, + ), + _supportSection(context, l10n), + ], + ), + ), if (auth.isSignedIn) ...[ - const AccountEntryCard(), - const SizedBox(height: AppDimens.spaceXl), + const SizedBox(height: 12), + _signOutButton(context, l10n), ], - const LegalLinksCard(), - const SizedBox(height: AppDimens.spaceXl), - _authAction(context, l10n, auth), ], ), ), ); } - Widget _authAction( - BuildContext context, - AppLocalizations l10n, - AuthController auth, - ) { - if (auth.isSignedIn) { - return OutlinedButton( + /// 退出登录:透明背景独立按钮,确认后清除本地会话并吊销刷新令牌。 + Widget _signOutButton(BuildContext context, AppLocalizations l10n) { + return SizedBox( + width: double.infinity, + height: 48, + child: TextButton( key: const Key('settings-sign-out'), - onPressed: () => unawaited(auth.signOut()), - child: Text(l10n.signOut), - ); - } - return FilledButton( - key: const Key('settings-sign-in'), - onPressed: () => unawaited(openAuthPage(context)), - child: Text(l10n.signIn), + style: TextButton.styleFrom( + backgroundColor: AppColors.transparent, + foregroundColor: AppColors.error, + ), + onPressed: () => _confirmSignOut(context, l10n), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.logout_rounded, size: 18), + const SizedBox(width: 6), + Text( + l10n.signOut, + style: const TextStyle( + fontSize: AppDimens.body, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ); + } + + Future _confirmSignOut(BuildContext context, AppLocalizations l10n) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AppGlowDialog( + title: l10n.signOut, + content: l10n.signOutConfirm, + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: AppColors.error), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.signOut), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + await context.read().signOut(); + } + + /// 常见问题 / 关于我们。 + Widget _supportSection(BuildContext context, AppLocalizations l10n) { + return Container( + key: const Key('settings-support'), + decoration: const BoxDecoration(color: AppColors.contentSurface), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _row( + key: const Key('settings-common-questions'), + label: l10n.helpFeedback, + icon: Icons.help_outline, + onTap: () => _pushPlaceholder(context, l10n), + ), + const Divider( + height: 1, + thickness: 1, + color: AppColors.contentBorder, + ), + _row( + key: const Key('settings-about'), + label: l10n.aboutUs, + icon: Icons.info_outline, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MineAboutPage()), + ), + ), + ], + ), + ); + } + + Widget _row({ + required Key key, + required String label, + required IconData icon, + required VoidCallback onTap, + }) { + return SizedBox( + height: 56, + child: InkWell( + key: key, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(icon, color: AppColors.textSecondary, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.body, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 20, + ), + ], + ), + ), + ), + ); + } + + void _pushPlaceholder(BuildContext context, AppLocalizations l10n) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlainPageScaffold( + pageKey: const Key('settings-placeholder-page'), + backKey: const Key('settings-placeholder-back'), + title: l10n.helpFeedback, + child: Center( + child: Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.body, + ), + ), + ), + ), + ), ); } } diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index b889936..7300b39 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -115,7 +115,7 @@ class WatchRecordCard extends StatelessWidget { value: record.progress.fraction, backgroundColor: AppColors.border, valueColor: const AlwaysStoppedAnimation( - AppColors.blue, + AppColors.brandRed, ), ), ), From 43e60e29ec0ee33331984a0e8c034707248718bb Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Wed, 28 Oct 2026 16:36:55 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(player):=20=E6=8E=A5=E5=85=A5=E5=8F=8C?= =?UTF-8?q?=E7=AB=AF=E7=94=BB=E4=B8=AD=E7=94=BB=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 4 +- android/app/src/main/AndroidManifest.xml | 3 + .../com/stormg/lumadrama/MainActivity.kt | 1 + ios/Runner/AppDelegate.swift | 399 +++++++++++++++++ ios/Runner/Info.plist | 4 + .../services/android_picture_in_picture.dart | 154 ++++++- lib/pages/player/player_logic.dart | 405 +++++++++++++++++- lib/pages/player/player_view.dart | 62 +++ lib/pages/player/player_view_actions.dart | 7 +- pubspec.yaml | 5 + 10 files changed, 1015 insertions(+), 29 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b6cae25..22499b7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,9 @@ plugins { android { namespace = "com.stormg.lumadrama" - compileSdk = flutter.compileSdkVersion + // permission_handler_android 14 的 AAR 元数据要求依赖方 compileSdk >= 37, + // 固定为 37(比 Flutter 默认的 36 高一档,只影响编译期 API 可见性)。 + compileSdk = 37 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e7df0ed..27046bf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + Void)? + /// 影子在画中画里播完当前集。 + var onPlaybackEnded: (() -> Void)? + + static var isSupported: Bool { + AVPictureInPictureController.isPictureInPictureSupported() + } + + private var keyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } + + /// 首次创建影子:1x1 图层挂在 keyWindow 的宿主 view 里(肉眼不可见),静音待命。 + /// [autoEnter] 为 true 时退后台由系统自动拉起画中画(需影子在播,见 armAutoEnter)。 + func prepare(urlString: String, positionMs: Int64, autoEnter: Bool) { + teardown() + log("prepare begin supported=\(PipShadowCoordinator.isSupported) autoEnter=\(autoEnter)") + guard Self.isSupported else { return } + guard let url = URL(string: urlString) else { + log("prepare failed bad url \(urlString)") + return + } + guard let window = keyWindow else { + log("prepare failed no key window") + return + } + let item = AVPlayerItem(url: url) + let p = AVPlayer(playerItem: item) + p.isMuted = true + p.actionAtItemEnd = .pause + let layer = AVPlayerLayer() + layer.player = p + let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + host.isUserInteractionEnabled = false + host.backgroundColor = .clear + window.addSubview(host) + layer.frame = host.bounds + host.layer.addSublayer(layer) + hostView = host + guard let c = AVPictureInPictureController(playerLayer: layer) else { + p.pause() + log("prepare failed no pip controller") + return + } + c.canStartPictureInPictureAutomaticallyFromInline = autoEnter + c.delegate = self + player = p + playerLayer = layer + controller = c + // 静音起播让 item 完成首帧解码,isPictureInPicturePossible 才会变 true。 + p.play() + possibleObservation = c.observe( + \.isPictureInPicturePossible, + options: [.new, .initial] + ) { [weak self] controller, change in + let value = change.newValue ?? controller.isPictureInPicturePossible + self?.log("possible changed to \(value)") + guard let self, self.pendingStart, controller.isPictureInPicturePossible else { return } + self.pendingStart = false + // 刚翻 true 的同一 runloop 里调用会被系统静默拒绝,推到下一拍再进。 + DispatchQueue.main.async { + guard self.controller === controller, controller.isPictureInPicturePossible else { + self.log("start via kvo aborted (shadow replaced)") + return + } + self.log("start via kvo") + controller.startPictureInPicture() + } + } + log("shadow ready, seeking to \(positionMs)ms") + if positionMs > 0 { + p.seek( + to: CMTime(value: positionMs, timescale: 1000), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + registerEndObserver(for: item) + activateAudioSession() + } + + /// 退出 App 前(inactive)把影子对齐到当前进度并静音起播: + /// auto PiP 只在「player 正在播放 + 图层离屏」时由系统触发。 + func armAutoEnter(positionMs: Int64) { + guard let player, let controller else { + log("arm failed, shadow missing") + return + } + guard controller.canStartPictureInPictureAutomaticallyFromInline else { + log("arm failed, autoEnter disabled on shadow") + return + } + log("arm auto enter at \(positionMs)ms") + if positionMs > 0 { + player.seek( + to: CMTime(value: positionMs, timescale: 1000), + toleranceBefore: CMTime(value: 800, timescale: 1000), + toleranceAfter: CMTime(value: 800, timescale: 1000) + ) + } + player.play() + } + + /// 回前台后取消待命:影子暂停回静音,等待下一次退后台。 + func disarmAutoEnter() { + guard let player else { return } + log("disarm auto enter") + player.pause() + player.isMuted = true + } + + /// 进入画中画:图层就绪则立即进入,否则 KVO/延时重试在就绪后自动进入。 + /// 仅当影子从未创建成功(prepare 失败)时返回 false。 + func start() -> Bool { + guard let controller else { + log("start failed, shadow not created") + return false + } + log("start requested possible=\(controller.isPictureInPicturePossible)") + pendingStart = true + if controller.isPictureInPicturePossible { + pendingStart = false + controller.startPictureInPicture() + } + scheduleStartRetries() + return true + } + + /// KVO 偶发不触发或流加载较慢时兜底:就绪后再试两次。 + private func scheduleStartRetries() { + startRetryWorkItems.forEach { $0.cancel() } + startRetryWorkItems.removeAll() + for delay in [1.0, 2.5] { + let item = DispatchWorkItem { [weak self] in + guard + let self, + self.pendingStart, + let controller = self.controller, + controller.isPictureInPicturePossible + else { return } + self.log("start via retry after \(delay)s") + self.pendingStart = false + controller.startPictureInPicture() + } + startRetryWorkItems.append(item) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + } + } + + /// 画中画内换集:只换 AVPlayerItem,不动 player/layer,窗口不中断。 + func replaceItem(urlString: String, positionMs: Int64) { + guard let player, let url = URL(string: urlString) else { return } + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + registerEndObserver(for: item) + if positionMs > 0 { + player.seek( + to: CMTime(value: positionMs, timescale: 1000), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + } + + /// 进入画中画后解除静音并起播(此时 Flutter 播放器已被暂停)。 + func unmuteAndPlay() { + player?.isMuted = false + player?.play() + } + + func positionMs() -> Int64 { + Int64(max(0, player?.currentTime().seconds ?? 0) * 1000) + } + + func stopPip() { + controller?.stopPictureInPicture() + } + + func teardown() { + startRetryWorkItems.forEach { $0.cancel() } + startRetryWorkItems.removeAll() + possibleObservation?.invalidate() + possibleObservation = nil + pendingStart = false + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + endObserver = nil + player?.pause() + player?.replaceCurrentItem(with: nil) + playerLayer?.removeFromSuperlayer() + playerLayer = nil + hostView?.removeFromSuperview() + hostView = nil + controller?.delegate = nil + controller = nil + player = nil + } + + /// 诊断日志:NSLog 之外同步转发到 Dart,便于在 flutter run 控制台观察。 + private func log(_ message: String) { + NSLog("pip: %@", message) + channel?.invokeMethod("pipDebug", arguments: message) + } + + private func registerEndObserver(for item: AVPlayerItem) { + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + self?.log("item did play to end") + self?.onPlaybackEnded?() + } + } + + /// 画中画的声音走影子播放器,需要 playback 类别的音频会话保活。 + private func activateAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + // 会话激活失败只影响后台出声,不阻断画中画。 + } + } + + func pictureInPictureControllerWillStartPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + log("will start") + pendingStart = false + onPipChanged?(true, 0) + } + + func pictureInPictureControllerWillStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + log("will stop, showing restore overlay") + showRestoreOverlay() + } + + func pictureInPictureControllerDidStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + log("did stop") + showRestoreOverlay() + onPipChanged?(false, positionMs()) + } + + /// 盖原生过渡层(幂等):主题红晕渐变,不依赖 Flutter 渲染。 + func showRestoreOverlay() { + guard let window = keyWindow else { return } + restoreOverlay?.removeFromSuperview() + let overlay = UIView(frame: window.bounds) + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + let gradient = CAGradientLayer() + gradient.frame = overlay.bounds + gradient.colors = [ + UIColor(red: 0.298, green: 0.086, blue: 0.102, alpha: 1).cgColor, + UIColor(red: 0.118, green: 0.043, blue: 0.047, alpha: 1).cgColor, + UIColor(red: 0.063, green: 0.063, blue: 0.071, alpha: 1).cgColor, + ] + gradient.locations = [0, 0.22, 0.62] + gradient.startPoint = CGPoint(x: 0.5, y: 0) + gradient.endPoint = CGPoint(x: 0.5, y: 1) + overlay.layer.addSublayer(gradient) + window.addSubview(overlay) + restoreOverlay = overlay + } + + /// 淡出并移除过渡层(视频首帧已确认后由 Dart 调用)。 + func hideRestoreOverlay() { + guard let overlay = restoreOverlay else { return } + restoreOverlay = nil + UIView.animate( + withDuration: 0.25, + animations: { overlay.alpha = 0 } + ) { _ in overlay.removeFromSuperview() } + } +} + @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private let wirelessDataPrompt = WirelessDataPrompt() @@ -64,6 +377,7 @@ final class RunnerFlutterViewController: FlutterViewController { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) registerWirelessDataChannel(engineBridge) registerDeviceIdChannel(engineBridge) + registerPipChannel(engineBridge) let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/share", binaryMessenger: engineBridge.applicationRegistrar.messenger() @@ -145,6 +459,91 @@ final class RunnerFlutterViewController: FlutterViewController { } } + /// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。 + private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) { + let channel = FlutterMethodChannel( + name: "com.stormg.lumadrama/pip", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + let pip = PipShadowCoordinator.shared + pip.channel = channel + channel.setMethodCallHandler { call, result in + switch call.method { + case "isAvailable": + result(PipShadowCoordinator.isSupported) + case "enter": + guard let args = call.arguments as? [String: Any], + let url = args["url"] as? String else { + result(false) + return + } + let positionMs = (args["positionMs"] as? NSNumber)?.int64Value ?? 0 + pip.prepare(urlString: url, positionMs: positionMs, autoEnter: false) + // 等一拍让 AVPlayerLayer 完成挂载再请求进入画中画。 + DispatchQueue.main.async { + result(pip.start()) + } + case "prepareShadow": + guard let args = call.arguments as? [String: Any], + let url = args["url"] as? String else { + result(nil) + return + } + pip.prepare( + urlString: url, + positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0, + autoEnter: (args["autoEnter"] as? NSNumber)?.boolValue ?? false + ) + result(nil) + case "armAutoEnter": + pip.armAutoEnter( + positionMs: (call.arguments as? [String: Any])?["positionMs"] as? Int64 ?? 0 + ) + result(nil) + case "showRestoreOverlay": + pip.showRestoreOverlay() + result(nil) + case "hideRestoreOverlay": + pip.hideRestoreOverlay() + result(nil) + case "disarmAutoEnter": + pip.disarmAutoEnter() + result(nil) + case "unmute": + pip.unmuteAndPlay() + result(nil) + case "replaceItem": + guard let args = call.arguments as? [String: Any], + let url = args["url"] as? String else { + result(nil) + return + } + pip.replaceItem( + urlString: url, + positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0 + ) + result(nil) + case "stop": + pip.stopPip() + result(nil) + case "teardown": + pip.teardown() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + pip.onPipChanged = { active, positionMs in + channel.invokeMethod( + "onPipChanged", + arguments: ["active": active, "positionMs": positionMs] + ) + } + pip.onPlaybackEnded = { + channel.invokeMethod("onPipPlaybackEnded", arguments: nil) + } + } + /// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。 /// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。 private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 1e27100..daecf90 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -87,6 +87,10 @@ + UIBackgroundModes + + audio + UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/lib/core/data/services/android_picture_in_picture.dart b/lib/core/data/services/android_picture_in_picture.dart index efb349d..a2f73fb 100644 --- a/lib/core/data/services/android_picture_in_picture.dart +++ b/lib/core/data/services/android_picture_in_picture.dart @@ -3,7 +3,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -/// Native Android activity PiP; the Flutter playback session keeps the player. +/// 原生活动级画中画桥:Android 是 Activity PiP(画面即 Flutter 渲染), +/// iOS 是影子播放器接力(画面/声音来自原生 AVPlayer,退出时进度回传)。 class AndroidPictureInPicture { AndroidPictureInPicture({MethodChannel? channel}) : _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip'); @@ -11,22 +12,147 @@ class AndroidPictureInPicture { final MethodChannel _channel; bool get isPlatformSupported => - !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); - void setStateListener(ValueChanged? listener) { - _channel.setMethodCallHandler( - listener == null - ? null - : (call) async { - if (call.method == 'onPipChanged') { - listener(call.arguments == true); - } - }, - ); + void Function(bool active, int finalPositionMs)? _pipChangedListener; + VoidCallback? _playbackEndedListener; + + /// PiP 状态回调:iOS 停止时 [finalPositionMs] 为影子最终进度(毫秒)。 + void setStateListener(void Function(bool active, int finalPositionMs)? listener) { + _pipChangedListener = listener; + _refreshHandler(); } - Future enter() async { + /// iOS 画中画内影子播完当前集(Android 不会触发)。 + void setPlaybackEndedListener(VoidCallback? listener) { + _playbackEndedListener = listener; + _refreshHandler(); + } + + void _refreshHandler() { + if (_pipChangedListener == null && _playbackEndedListener == null) { + _channel.setMethodCallHandler(null); + return; + } + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'pipDebug': + debugPrint('pip[native]: ${call.arguments}'); + case 'onPipChanged': + final listener = _pipChangedListener; + if (listener == null) return; + final args = call.arguments; + if (args is Map) { + listener( + args['active'] == true, + (args['positionMs'] as num?)?.toInt() ?? 0, + ); + } else { + // Android 只回传 bool。 + listener(args == true, 0); + } + case 'onPipPlaybackEnded': + _playbackEndedListener?.call(); + } + }); + } + + /// 进入画中画。Android 忽略片源参数;iOS 用 [url] 与 [positionMs] + /// 初始化影子播放器(对齐到当前进度、静音待命)。 + Future enter({required String url, required int positionMs}) async { if (!isPlatformSupported) return false; - return await _channel.invokeMethod('enter') ?? false; + return await _channel.invokeMethod('enter', { + 'url': url, + 'positionMs': positionMs, + }) ?? + false; + } + + /// 创建影子播放器但不起播(仅 iOS): + /// 自动小窗待命形态——静音暂停,退后台由 [armAutoEnter] 拉起。 + Future prepareShadow({ + required String url, + required int positionMs, + bool autoEnter = false, + }) async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('prepareShadow', { + 'url': url, + 'positionMs': positionMs, + 'autoEnter': autoEnter, + }); + } catch (_) {} + } + + /// 退后台瞬间把影子对齐到当前进度并静音起播,触发系统自动画中画(仅 iOS)。 + Future armAutoEnter(int positionMs) async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('armAutoEnter', { + 'positionMs': positionMs, + }); + } catch (_) {} + } + + /// 回前台后影子回待命(暂停、静音)(仅 iOS)。 + Future disarmAutoEnter() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('disarmAutoEnter'); + } catch (_) {} + } + + /// 显示原生还原过渡层(仅 iOS;还原动画开始时原生侧也会自动盖上)。 + Future showRestoreOverlay() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('showRestoreOverlay'); + } catch (_) {} + } + + /// 淡出原生还原过渡层(仅 iOS;视频首帧确认后调用)。 + Future hideRestoreOverlay() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('hideRestoreOverlay'); + } catch (_) {} + } + + /// 解除影子静音并起播(仅 iOS 有意义,进入 PiP 后声音交给影子)。 + Future unmute() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('unmute'); + } catch (_) {} + } + + /// iOS 画中画内换集:影子只换播放项,窗口不中断。 + Future replaceItem({required String url, required int positionMs}) async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('replaceItem', { + 'url': url, + 'positionMs': positionMs, + }); + } catch (_) {} + } + + /// 请求退出画中画(iOS;触发 onPipChanged(false) 后由 Dart 收尾)。 + Future stop() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('stop'); + } catch (_) {} + } + + /// 销毁影子播放器(仅 iOS)。 + Future teardown() async { + if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return; + try { + await _channel.invokeMethod('teardown'); + } catch (_) {} } } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa3e95e..acdbb65 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -1,12 +1,16 @@ // Author: fruit.com.cn import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; +import 'package:floating/floating.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/episode_stats.dart'; @@ -44,6 +48,16 @@ typedef _PlaybackSnapshot = ({ }); class PlayerLogic extends ChangeNotifier { + /// Android PiP 管理器(floating 插件);负责退后台自动进小窗。 + static final Floating _floating = Floating(); + + /// 挂了自动小窗的播放器实例(引用计数):沉浸流与独立播放页可能 + /// 同时在树上,只有全部不需要时才真正注销 OnLeavePiP。 + static final Set _pipOwners = {}; + + /// OnLeavePiP 当前在原生侧的挂载状态(进程级,与 [_pipOwners] 对应)。 + static bool _pipNativeArmed = false; + PlayerLogic({ required this.history, required this.playback, @@ -112,7 +126,31 @@ class PlayerLogic extends ChangeNotifier { VideoPlayerPort? scrubbingPlayer; bool pip = false; bool pipPreparing = false; - final Map pickedEpisode = {}; + bool _wasPlayingBeforeIosPip = false; + Timer? _pipStartTimer; + + /// iOS 自动小窗开关(对齐红果:开启后退 App 自动进入画中画)。 + static const _iosAutoPipPrefKey = 'player.ios.autoEnterPip'; + bool iosAutoPipEnabled = false; + + /// 画中画还原信号:自增通知视频表面回退占位重新等首帧(防回前台黑屏)。 + int pipResumeToken = 0; + + /// 画中画还原过渡期:为 true 时播放区盖当前集封面,等纹理出帧后淡出, + /// 盖住回前台瞬间的黑纹理帧。700ms 后自动复位。 + bool pipRecovering = false; + Timer? _pipRecoverTimer; + + /// iOS 影子播放器当前承载的集 id(全局单例状态);用于避免重复换源。 + static String? iosShadowEpisodeId; + + /// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。 + bool get iosDevice => !kIsWeb && Platform.isIOS; + /// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic: + /// 推荐页每页一个 logic,若只保留最后一次注册的 handler, + /// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。 + static final Set _pipSubscribers = {}; + static final Map pickedEpisode = {}; final Set _unlockedEpisodeIds = {}; final Map _episodeStats = {}; final Map _episodeStatsFetchedAt = {}; @@ -214,7 +252,10 @@ class PlayerLogic extends ChangeNotifier { _started = true; _sessionFactory = factory; _sessionCoordinator = coordinator; - pictureInPicture.setStateListener(onPipChanged); + _pipSubscribers.add(this); + pictureInPicture.setStateListener(_dispatchPipChanged); + pictureInPicture.setPlaybackEndedListener(_dispatchPipPlaybackEnded); + unawaited(_loadIosAutoPipPref()); queue = feedDramas == null ? PlaybackQueue.series(pageDrama) : PlaybackQueue.home(feedDramas!); @@ -850,6 +891,11 @@ class PlayerLogic extends ChangeNotifier { if (pip || pipPreparing) { pip = false; pipPreparing = false; + if (iosDevice) { + // 部分回前台路径不回调 didStop:销毁影子、seek 刷纹理并恢复播放, + // 否则停在 PiP 期间的暂停态表现为黑屏;自动小窗开着则重建待命。 + unawaited(_recoverFromIosPipFallback()); + } notify(); notifyImmersive(); } @@ -873,6 +919,26 @@ class PlayerLogic extends ChangeNotifier { state == AppLifecycleState.resumed || pip || pipPreparing, ); if (state != AppLifecycleState.resumed) cancelScrub(); + // iOS 自动小窗:退后台瞬间把影子对齐到当前进度起播,系统据此自动进画中画; + // 回前台取消待命。手动小窗的恢复由 onPipChanged(false) 处理。 + if (iosDevice && iosAutoPipEnabled && !pip) { + final video = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + if (state == AppLifecycleState.inactive && + video != null && + session.userWantsPlay) { + debugPrint('pip: lifecycle inactive -> arm auto enter'); + unawaited( + pictureInPicture.armAutoEnter( + video.controller.value.position.inMilliseconds, + ), + ); + } else if (state == AppLifecycleState.resumed) { + debugPrint('pip: lifecycle resumed -> disarm auto enter'); + unawaited(pictureInPicture.disarmAutoEnter()); + } + } } Future _refreshTicketThenResume( @@ -904,7 +970,13 @@ class PlayerLogic extends ChangeNotifier { void dispose() { if (_disposed) return; _disposed = true; - pictureInPicture.setStateListener(null); + _pipSubscribers.remove(this); + _pipStartTimer?.cancel(); + if (_pipSubscribers.isEmpty) { + pictureInPicture.setStateListener(null); + pictureInPicture.setPlaybackEndedListener(null); + } + unawaited(syncPipAutoArm(false)); unawaited(saveCurrentProgress()); _detachTrackedPlayerListener(); if (_started) { @@ -918,31 +990,117 @@ class PlayerLogic extends ChangeNotifier { void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); + /// 按 [want] 挂/摘退后台自动小窗(引用计数): + /// - 沉浸流:随所在 tab 的激活状态调用(active 时挂,切走时摘); + /// - 独立播放页:进入时挂、销毁时摘; + /// - 权限未授权时静默跳过(申请统一在首次点「画中画」时,见 [enterPip])。 + Future syncPipAutoArm(bool want) async { + if (!pictureInPicture.isPlatformSupported) return; + // iOS 无「退后台自动小窗」(系统不开放 Activity 级自动进入),走手动入口。 + if (!kIsWeb && Platform.isIOS) return; + try { + if (want) { + _pipOwners.add(this); + } else { + _pipOwners.remove(this); + } + final arm = _pipOwners.isNotEmpty; + if (arm == _pipNativeArmed) return; + if (arm) { + if (!await _floating.isPipAvailable) return; + if (!await Permission.systemAlertWindow.isGranted) return; + await _floating.enable(const OnLeavePiP(aspectRatio: Rational(9, 16))); + _pipNativeArmed = true; + } else { + _pipNativeArmed = false; + await _floating.cancelOnLeavePiP(); + } + } catch (_) { + // 自动小窗挂载失败不影响正常播放。 + } + } + Future enterPip(BuildContext context) async { - if (pip || pipPreparing) return; - if (session.active is! VideoPlayerPort) { + if (pip || pipPreparing) { + debugPrint('pip: tap ignored (pip=$pip preparing=$pipPreparing)'); + return; + } + final isIos = !kIsWeb && Platform.isIOS; + debugPrint('pip: tap platform=${isIos ? "ios" : "android"}'); + final activeVideo = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + if (activeVideo == null) { + debugPrint('pip: no active video port'); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), ); return; } + // 首次点击「画中画」:申请「显示在其他应用上层」权限(特殊权限, + // 会跳系统设置页);拒绝则视为设备无法使用画中画。仅 Android 需要。 + if (!isIos && pictureInPicture.isPlatformSupported) { + try { + if (!await Permission.systemAlertWindow.isGranted) { + await Permission.systemAlertWindow.request(); + } + if (await Permission.systemAlertWindow.isGranted) { + _pipOwners.add(this); + if (!_pipNativeArmed && await _floating.isPipAvailable) { + await _floating.enable( + const OnLeavePiP(aspectRatio: Rational(9, 16)), + ); + _pipNativeArmed = true; + } + } else { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.pipUnavailable), + ), + ); + return; + } + } catch (_) { + // 权限流程异常时按原有流程继续尝试直接进入。 + } + } pipPreparing = true; notify(); notifyImmersive(); await WidgetsBinding.instance.endOfFrame; + final url = VideoPlayerPort.resolveUri( + sessionEpisodes[session.currentIndex], + ).toString(); + final positionMs = activeVideo.controller.value.position.inMilliseconds; + debugPrint('pip: invoke enter pos=$positionMs'); bool entered = false; try { - entered = await pictureInPicture.enter(); - } catch (_) { + entered = await pictureInPicture.enter(url: url, positionMs: positionMs); + } catch (e) { + debugPrint('pip: enter threw $e'); // Android reports unavailable for devices without PiP support. } + debugPrint('pip: entered=$entered'); if (!context.mounted) return; if (entered) { - pip = true; - pipPreparing = false; - notify(); - notifyImmersive(); - session.setAppResumed(true); + if (isIos) { + // iOS 等影子流就绪后自动进入小窗(原生 pendingStart 兜底不会过期); + // 超时仅复位按钮状态,避免长时间卡在「准备中」。 + _pipStartTimer?.cancel(); + _pipStartTimer = Timer(const Duration(seconds: 6), () { + if (!isMounted() || pip || !pipPreparing) return; + pipPreparing = false; + notify(); + notifyImmersive(); + }); + } else { + pip = true; + pipPreparing = false; + notify(); + notifyImmersive(); + session.setAppResumed(true); + } } else { pipPreparing = false; notify(); @@ -953,10 +1111,28 @@ class PlayerLogic extends ChangeNotifier { } } - void onPipChanged(bool enabled) { + static void _dispatchPipChanged(bool enabled, int finalPositionMs) { + for (final logic in List.of(_pipSubscribers)) { + logic.onPipChanged(enabled, finalPositionMs: finalPositionMs); + } + } + + static void _dispatchPipPlaybackEnded() { + for (final logic in List.of(_pipSubscribers)) { + logic._handleIosPipPlaybackEnded(); + } + } + + void onPipChanged(bool enabled, {int finalPositionMs = 0}) { if (!isMounted()) return; + if (!kIsWeb && Platform.isIOS) { + _onIosPipChanged(enabled, finalPositionMs); + return; + } pip = enabled; pipPreparing = false; + _pipStartTimer?.cancel(); + _pipStartTimer = null; notify(); notifyImmersive(); session.setAppResumed( @@ -965,6 +1141,94 @@ class PlayerLogic extends ChangeNotifier { ); } + /// iOS 影子接力:进入小窗后画面/声音交给原生影子播放器, + /// Flutter 播放器暂停(避免双声);退出时按影子进度 seek 对齐恢复。 + void _onIosPipChanged(bool enabled, int finalPositionMs) { + final video = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + pip = enabled; + pipPreparing = false; + _pipStartTimer?.cancel(); + _pipStartTimer = null; + if (enabled) { + _wasPlayingBeforeIosPip = session.userWantsPlay; + session.setUserWantsPlay(false); + if (video != null) unawaited(video.pause()); + unawaited(pictureInPicture.unmute()); + session.setAppResumed(true); + } else { + unawaited(pictureInPicture.teardown()); + PlayerLogic.iosShadowEpisodeId = null; + pipResumeToken++; + _beginPipRecover(); + if (video != null) { + final target = finalPositionMs > 0 + ? clampResumePosition( + Duration(milliseconds: finalPositionMs), + video.controller.value.duration, + ) + : video.controller.value.position; + // seek 同时兜住进度对齐与回前台纹理黑屏(强制 AVPlayer 重新出帧)。 + unawaited(video.seekTo(target)); + } + session.setUserWantsPlay(_wasPlayingBeforeIosPip); + session.setAppResumed( + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, + ); + // 自动小窗开着时影子回待命,等待下次退后台。 + if (iosAutoPipEnabled) { + final episode = trackedEpisode; + if (episode != null) unawaited(_ensureIosShadowStandby(episode)); + } + } + notify(); + notifyImmersive(); + } + + /// iOS 画中画里影子播完当前集:小窗内直接换下一集片源无缝连播(不翻页), + /// 同时用无动画跳页把 App 内位置同步到新集,还原后正好停在新集; + /// 没有下一集或下一集锁定时退出画中画回到 App。 + Future _handleIosPipPlaybackEnded() async { + if (!isMounted() || !pip || !(!kIsWeb && Platform.isIOS)) return; + final index = session.currentIndex; + final positionInVisible = visibleIndices.indexOf(index) + 1; + if (positionInVisible <= 0 || positionInVisible >= visibleIndices.length) { + await pictureInPicture.stop(); + return; + } + final nextIndex = visibleIndices[positionInVisible]; + if (nextIndex < 0 || nextIndex >= sessionEpisodes.length) { + await pictureInPicture.stop(); + return; + } + if (isEpisodeLocked(sessionEpisodes[nextIndex])) { + await pictureInPicture.stop(); + return; + } + final episode = sessionEpisodes[nextIndex]; + debugPrint('pip: auto next episode=${episode.id}'); + // 影子直接切下一集从头播,窗口不中断。 + try { + await pictureInPicture.replaceItem( + url: VideoPlayerPort.resolveUri(episode).toString(), + positionMs: 0, + ); + await pictureInPicture.unmute(); + PlayerLogic.iosShadowEpisodeId = episode.id; + } catch (_) { + await pictureInPicture.stop(); + return; + } + // 同步 App 内会话与页面位置(无动画),还原后停在当前连播到的集。 + final controller = pages; + try { + if (controller.hasClients) controller.jumpToPage(positionInVisible); + } catch (_) { + // 页面可能已离树,影子连播不受影响。 + } + } + Future preparePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; try { @@ -1020,6 +1284,121 @@ class PlayerLogic extends ChangeNotifier { _completionProgressSaved = false; lastProgressWrite = DateTime.now(); player.controller.addListener(_handleTrackedPlayerChanged); + // iOS:小窗内换集则影子直切新片源继续播;自动小窗开启时影子换源保持待命。 + // 影子已在该集(连播刚换过源)时跳过,避免重置回片头。 + if (!kIsWeb && Platform.isIOS) { + if (pip) { + if (PlayerLogic.iosShadowEpisodeId != episode.id) { + unawaited(_syncIosShadowToEpisode(episode, playWhenPip: true)); + } + } else if (iosAutoPipEnabled) { + unawaited(_ensureIosShadowStandby(episode)); + } + } + } + + Future _syncIosShadowToEpisode( + Episode episode, { + required bool playWhenPip, + }) async { + debugPrint('pip: shadow sync episode=${episode.id} play=$playWhenPip'); + try { + await pictureInPicture.replaceItem( + url: VideoPlayerPort.resolveUri(episode).toString(), + positionMs: 0, + ); + PlayerLogic.iosShadowEpisodeId = episode.id; + if (playWhenPip) await pictureInPicture.unmute(); + } catch (_) { + // 影子切换失败时保持小窗停在旧集末帧,不阻断 Flutter 侧换集。 + } + } + + /// 读取自动小窗开关;开启时影子待命由集激活钩子建立。 + Future _loadIosAutoPipPref() async { + if (!iosDevice) return; + try { + iosAutoPipEnabled = + await SharedPreferencesAsync().getBool(_iosAutoPipPrefKey) ?? false; + } catch (_) { + iosAutoPipEnabled = false; + } + } + + /// 切换自动小窗开关(更多面板):开启即建影子待命,关闭即销毁。 + Future setIosAutoPip(bool enabled) async { + debugPrint('pip: auto-pip switch -> $enabled'); + if (!iosDevice || iosAutoPipEnabled == enabled) return; + iosAutoPipEnabled = enabled; + try { + await SharedPreferencesAsync().setBool(_iosAutoPipPrefKey, enabled); + } catch (_) { + // 偏好写失败只影响下次启动的记忆,本次会话照常生效。 + } + if (enabled) { + final index = session.currentIndex; + if (index >= 0 && index < sessionEpisodes.length) { + await _ensureIosShadowStandby(sessionEpisodes[index]); + } + } else { + await pictureInPicture.teardown(); + PlayerLogic.iosShadowEpisodeId = null; + } + } + + /// 还原过渡:盖封面直至视频真正出帧(onVideoVisible 时收起),兜底 900ms。 + void _beginPipRecover() { + _pipRecoverTimer?.cancel(); + pipRecovering = true; + _pipRecoverTimer = Timer(const Duration(milliseconds: 900), endPipRecover); + } + + /// 视频首帧确认后立即收起还原封面(由播放视图回调)。 + void endPipRecover() { + _pipRecoverTimer?.cancel(); + if (pipRecovering) { + pipRecovering = false; + notify(); + } + // 原生过渡层在还原动画开始时已盖上,这里等表面恢复后淡出。 + unawaited(pictureInPicture.hideRestoreOverlay()); + } + + /// 回前台兜底恢复:销毁影子、seek 强制重新出帧并恢复播放, + /// 修复部分还原路径不回调 didStop 导致的黑屏 + 暂停。 + Future _recoverFromIosPipFallback() async { + pipResumeToken++; + _beginPipRecover(); + await pictureInPicture.teardown(); + PlayerLogic.iosShadowEpisodeId = null; + final video = session.active is VideoPlayerPort + ? session.active as VideoPlayerPort + : null; + if (video != null) { + await video.seekTo(video.controller.value.position); + session.setUserWantsPlay(true); + } + session.setAppResumed(true); + final episode = trackedEpisode; + if (iosAutoPipEnabled && episode != null) { + await _ensureIosShadowStandby(episode); + } + } + + /// 影子待命形态:静音暂停在片头,退后台时由系统自动切画中画。 + Future _ensureIosShadowStandby(Episode episode) async { + if (!iosDevice || pip) return; + try { + debugPrint('pip: prepare shadow standby (autoEnter)'); + await pictureInPicture.prepareShadow( + url: VideoPlayerPort.resolveUri(episode).toString(), + positionMs: 0, + autoEnter: true, + ); + PlayerLogic.iosShadowEpisodeId = episode.id; + } catch (e) { + debugPrint('pip: prepare standby failed: $e'); + } } void _detachTrackedPlayerListener() { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 6aede6d..2144391 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -200,6 +200,8 @@ class _PlayerPageState extends State ); } _pageController = logic.pages; + // 自动小窗:独立播放页进入即挂;沉浸流随所在 tab 的激活状态挂/摘。 + unawaited(logic.syncPipAutoArm(widget.embedded ? widget.active : true)); if (!widget.embedded && !logic.isEpisodeLocked(logic.session.currentEpisode)) { _loadingVisible = true; @@ -217,6 +219,10 @@ class _PlayerPageState extends State void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); logic.attach(widget, isMounted: () => mounted); + // 沉浸流所在 tab 切换时同步自动小窗(激活挂、切走摘)。 + if (widget.embedded && oldWidget.active != widget.active) { + unawaited(logic.syncPipAutoArm(widget.active)); + } if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) { _cancelFeedScrub(); } @@ -703,6 +709,13 @@ class _PlayerPageState extends State onRefresh: _refreshHomeFeed, child: pages, ); + // 进画中画时预加载当前集海报:还原过渡封面立即可显示,避免再黑一次。 + if (logic.pip) { + final poster = logic.trackedEpisode?.posterUrl?.trim(); + if (poster != null && poster.isNotEmpty) { + unawaited(precacheImage(NetworkImage(poster), context)); + } + } return Stack( fit: StackFit.expand, clipBehavior: Clip.none, @@ -711,7 +724,13 @@ class _PlayerPageState extends State pageController: _pageController, slots: _renderSlots(indices), onCurrentVideoVisible: _handleCurrentVideoVisible, + resumeSignal: logic.pipResumeToken, ), + if (logic.pipRecovering) + Positioned.fill( + key: const Key('player-pip-recover-cover'), + child: _PipRecoverCover(episode: logic.trackedEpisode), + ), if (!widget.embedded && !_loadingRemoved) Center( child: IgnorePointer( @@ -804,6 +823,9 @@ class _PlayerPageState extends State void _handleCurrentVideoVisible(int? index) { if (!mounted || _loadingRemoved) return; + // 收起还原封面要放在 early-return 之前:恢复时集未变, + // _videoReadyIndex 已相等,只有这里能接到淡入完成的回调。 + logic.endPipRecover(); final currentIndex = logic.session.currentIndex; if (index == null || index != currentIndex || @@ -1447,3 +1469,43 @@ class _PlayerPageState extends State ); } } + +/// 画中画还原过渡封面:盖住回前台瞬间的黑纹理帧,视频出帧后由 +/// [PlayerLogic.endPipRecover] 收起。背景用主题渐变,海报预加载命中前也有过渡观感。 +class _PipRecoverCover extends StatelessWidget { + const _PipRecoverCover({required this.episode}); + + final Episode? episode; + + @override + Widget build(BuildContext context) { + final poster = episode?.posterUrl?.trim(); + final thumbnail = episode?.thumbnailsUrl?.trim(); + final url = poster != null && poster.isNotEmpty + ? poster + : (thumbnail != null && thumbnail.isNotEmpty ? thumbnail : null); + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + Color(0xFF101012), + ], + stops: [0, 0.22, 0.62], + ), + ), + child: url == null + ? const SizedBox.shrink() + : Image.network( + url, + key: const Key('player-pip-recover-cover-image'), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ); + } +} diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index d78f3d0..3337dcf 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -37,9 +37,14 @@ extension _PlayerViewActions on _PlayerPageState { onQualityChanged: (quality) => _switchQuality(context, l10n, quality), onClear: _hideChrome, onDownload: () => requestEpisodeDownload(context, episode.id), - onPip: logic.pictureInPicture.isPlatformSupported + // iOS 不显示手动「画中画」入口(走下方「退出后自动小窗」开关)。 + onPip: logic.pictureInPicture.isPlatformSupported && !logic.iosDevice ? () => unawaited(logic.enterPip(context)) : null, + autoPipEnabled: logic.iosAutoPipEnabled, + onAutoPipChanged: logic.iosDevice + ? (value) => unawaited(logic.setIosAutoPip(value)) + : null, ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 0c1a46b..64d1b19 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,10 @@ dependencies: sdk: flutter path_provider: 2.1.5 html: ^0.15.6 + # 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗 + floating: ^6.0.0 + # 运行时权限:systemAlertWindow(「显示在其他应用上层」) + permission_handler: ^13.0.2 dev_dependencies: flutter_test: @@ -86,6 +90,7 @@ flutter: - assets/icon/splash_logo.png - assets/icon/splash_logo_android12.png - assets/icon/rail/ + - assets/auth/ # To add assets to your application, add an assets section, like this: # assets: From 85dedf20fa7ae90773b52e5fdc1bbe2dd83299a2 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Wed, 28 Oct 2026 16:36:55 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D=E6=B8=85?= =?UTF-8?q?=E6=99=B0=E5=BA=A6=E5=88=87=E6=8D=A2=E9=97=AA=E5=B1=8F=E4=B8=8E?= =?UTF-8?q?=E8=BF=98=E5=8E=9F=E9=BB=91=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/playback_session.dart | 2 +- .../services/watch_records_controller.dart | 8 + .../player/widgets/home_feed_overlay.dart | 57 ++-- .../widgets/home_feed_recommend_panel.dart | 121 ++++--- lib/pages/player/widgets/player_overlay.dart | 10 +- .../player/widgets/player_overlay_parts.dart | 313 ++++++------------ .../player/widgets/player_render_stack.dart | 5 + .../player/widgets/player_video_surface.dart | 29 ++ 8 files changed, 249 insertions(+), 296 deletions(-) diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 9480288..ecc7036 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -11,7 +11,7 @@ part 'playback_neighbors.dart'; part 'playback_session_controls.dart'; /// 新纹理第一帧常是黑的,旧画面只盖住这一小段。 -const Duration _qualityHandoffHold = Duration(milliseconds: 200); +const Duration _qualityHandoffHold = Duration(milliseconds: 500); typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerPreparation = diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 5167541..3ff2094 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -144,6 +144,14 @@ class WatchRecordsController extends ChangeNotifier { return _detailCache[dramaId]; } + /// 按剧集 id 找最近观看记录,供收藏网格显示「看到第n集」与继续观看横幅。 + WatchRecord? recordForDrama(String dramaId) { + for (final record in records) { + if (record.dramaId == dramaId) return record; + } + return null; + } + /// 删除单条记录,先更新 UI,失败按原位置回滚。 Future remove(String dramaId) async { final index = _recordIndex(dramaId); diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index b281f23..b97f73a 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -149,33 +149,6 @@ class _HomeFeedOverlayState extends State { onInteractionEnd: widget.onBoostEnd, ), ), - Positioned( - left: _pageHorizontal, - right: _railRight, - bottom: _bottomInfoInset, - child: AnimatedOpacity( - key: const Key('home-feed-content-opacity'), - duration: _overlayFadeDuration, - opacity: widget.visible && !_scrubbing && !widget.boosting - ? 1 - : 0, - child: IgnorePointer( - key: const Key('home-feed-content-ignore'), - ignoring: !widget.visible || _scrubbing || widget.boosting, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _bottomInfo()), - const SizedBox(width: _railGap), - Padding( - padding: const EdgeInsets.only(bottom: _railBottomOffset), - child: _rail(), - ), - ], - ), - ), - ), - ), Positioned( left: 0, right: 0, @@ -227,6 +200,36 @@ class _HomeFeedOverlayState extends State { ), ), ), + // 信息面板放在最后(最上层):进度条 32px 触控盒向上伸进底部避让区, + // 若面板在前,简介/展开按钮底部的触控留白会被进度条吞掉点击。 + // 视觉上避让条带为空,提层不改变观感。 + Positioned( + left: _pageHorizontal, + right: _railRight, + bottom: _bottomInfoInset, + child: AnimatedOpacity( + key: const Key('home-feed-content-opacity'), + duration: _overlayFadeDuration, + opacity: widget.visible && !_scrubbing && !widget.boosting + ? 1 + : 0, + child: IgnorePointer( + key: const Key('home-feed-content-ignore'), + ignoring: !widget.visible || _scrubbing || widget.boosting, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _bottomInfo()), + const SizedBox(width: _railGap), + Padding( + padding: const EdgeInsets.only(bottom: _railBottomOffset), + child: _rail(), + ), + ], + ), + ), + ), + ), ], ), ); diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 7352b31..61ed40c 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -10,9 +10,9 @@ import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; import 'interaction_rail.dart'; -const double _seriesFont = 21; -const double _tagFont = 14; -const double _synopsisFont = 15; +const double _seriesFont = 17; +const double _tagFont = 12; +const double _synopsisFont = 14; const double _watchAllHeight = 44; const List _textShadows = [ Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), @@ -90,7 +90,7 @@ class _HomeFeedInfoPanelState extends State { style: const TextStyle( color: AppColors.textPrimary, fontSize: _seriesFont, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, shadows: _textShadows, ), ), @@ -98,7 +98,7 @@ class _HomeFeedInfoPanelState extends State { const SizedBox(width: 2), const Icon( Icons.chevron_right_rounded, - size: 20, + size: 18, color: AppColors.textPrimary, ), ], @@ -127,13 +127,13 @@ class _HomeFeedInfoPanelState extends State { ]; return SizedBox( key: const Key('home-feed-tags'), - height: 25, + height: 22, child: ListView.separated( scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), + physics: const BouncingScrollPhysics(), shrinkWrap: true, itemCount: chips.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), + separatorBuilder: (_, _) => const SizedBox(width: 6), itemBuilder: (_, index) => chips[index], ), ); @@ -151,7 +151,15 @@ class _HomeFeedInfoPanelState extends State { children: [ TextSpan( text: prefix, - style: baseStyle.copyWith(fontWeight: FontWeight.w600), + style: baseStyle, + ), + TextSpan( + text: ' | ', + style: baseStyle.copyWith( + color: AppColors.textPrimary.withValues(alpha: 0.45), + fontSize: _synopsisFont - 2, + fontWeight: FontWeight.w400, + ), ), TextSpan(text: synopsis, style: baseStyle), ], @@ -164,6 +172,7 @@ class _HomeFeedInfoPanelState extends State { final toggle = _synopsisToggle( key: const Key('home-feed-synopsis-more'), label: l10n.expandSynopsis, + padding: const EdgeInsets.only(left: 6), ); final painter = TextPainter( text: span, @@ -180,21 +189,28 @@ class _HomeFeedInfoPanelState extends State { ); } // 设计稿:单行省略号 + 行内固定「展开」。 - return Row( - key: const Key('home-feed-synopsis-collapsed'), - children: [ - Expanded( - child: Text.rich( - span, - key: const Key('home-feed-synopsis'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: baseStyle, + // 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。 + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _synopsisExpanded = true), + child: Row( + key: const Key('home-feed-synopsis-collapsed'), + // 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。 + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text.rich( + span, + key: const Key('home-feed-synopsis'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: baseStyle, + ), ), - ), - const SizedBox(width: 2), - toggle, - ], + const SizedBox(width: 2), + toggle, + ], + ), ); }, ); @@ -205,38 +221,50 @@ class _HomeFeedInfoPanelState extends State { TextStyle style, AppLocalizations l10n, ) { - return Column( - key: const Key('home-feed-synopsis-expanded'), - crossAxisAlignment: CrossAxisAlignment.start, + // 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间 + // 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。 + final expandedSpan = TextSpan( + style: style, children: [ - Text.rich( - span, - key: const Key('home-feed-synopsis'), - style: style, - ), - const SizedBox(height: 2), - Align( - alignment: Alignment.centerRight, + span, + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, child: _synopsisToggle( key: const Key('home-feed-synopsis-more'), label: l10n.showLess, + padding: const EdgeInsets.only(left: 8), ), ), ], ); + return Text.rich( + expandedSpan, + key: const Key('home-feed-synopsis-expanded'), + ); } - Widget _synopsisToggle({required Key key, required String label}) { + Widget _synopsisToggle({ + required Key key, + required String label, + required EdgeInsets padding, + }) { return GestureDetector( key: key, + // opaque + 留白扩大命中区;留白方向由调用方决定, + // 展开态不能加 top padding,否则「收起」距简介正文过远。 + behavior: HitTestBehavior.opaque, onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), - child: Text( - label, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _synopsisFont, - fontWeight: FontWeight.w600, - shadows: _textShadows, + child: Padding( + padding: padding, + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _synopsisFont, + fontWeight: FontWeight.w600, + shadows: _textShadows, + ), ), ), ); @@ -317,7 +345,7 @@ class _TagChip extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), alignment: Alignment.center, decoration: BoxDecoration( color: filled @@ -426,11 +454,11 @@ class HomeFeedActionRail extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return InteractionRail( - itemGap: 18, + itemGap: 12, buttonSize: 48, iconSize: 36, countFontSize: 15, - iconCountGap: 6, + iconCountGap: 2, items: [ InteractionRailItem( key: const Key('rail-save'), @@ -462,8 +490,7 @@ class HomeFeedActionRail extends StatelessWidget { ), InteractionRailItem( key: const Key('rail-share'), - icon: Icons.reply_rounded, - mirrorIcon: true, + asset: 'assets/icon/rail/rail_share.png', label: l10n.share, countKey: const Key('rail-share-label'), onTap: onShare, diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index a89f284..b510ee0 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -158,16 +158,20 @@ class PlayerPlaybackOverlay extends StatelessWidget { } Widget _rail(BuildContext context) { - final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( right: AppDimens.spaceMd, - top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43), + // 底部锚定:无论屏幕高矮,始终悬在进度条上方,不会压到进度条。 + bottom: 120, child: IgnorePointer( ignoring: scrubbing, child: Opacity( opacity: scrubbing ? 0 : 1, child: InteractionRail( key: const Key('player-interaction-rail'), + itemGap: 12, + buttonSize: 48, + iconSize: 36, + countFontSize: 15, items: [ InteractionRailItem( key: const Key('player-rail-save'), @@ -336,7 +340,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { color: AppColors.onPrimary, fontFamily: 'Outfit', fontSize: 18, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w400, shadows: _playerTextShadows, ), ), diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 7bd0744..bd8320f 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -131,9 +131,9 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { int _detailRequest = 0; bool _detailRequested = false; - /// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。 + /// 「第 n 集」、分隔符与简介正文共用同一套白字规格。 static const TextStyle _lineStyle = TextStyle( - color: AppColors.playerMuted, + color: AppColors.onPrimary, fontSize: 12, fontWeight: FontWeight.w500, height: 1.35, @@ -142,13 +142,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { static const TextStyle _linkStyle = TextStyle( color: AppColors.textPrimary, fontSize: 12, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, shadows: _playerTextShadows, ); static const int _collapsedMaxLines = 2; - static const double _expandGap = 6; - static const double _expandTrailing = 28; - static const double _separatorGap = 6; String get _synopsis => _fullSynopsis ?? widget.synopsis; @@ -200,236 +197,116 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { ); } + /// 与首页推荐 feed 的简介展开/收起保持一致: + /// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。 Widget _block(double maxWidth) { final label = widget.l10n.episodeLabel(widget.episodeNumber); - if (_expanded) return _expandedBlock(label, maxWidth); - return _collapsedBlock(label, maxWidth); + final span = TextSpan( + style: _lineStyle, + children: [ + TextSpan(text: label, style: _lineStyle), + _separatorSpan(), + TextSpan(text: _synopsis, style: _lineStyle), + ], + ); + if (_expanded) return _expandedBlock(span); + + final painter = TextPainter( + text: span, + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: maxWidth); + if (!painter.didExceedMaxLines) { + return Text.rich( + span, + key: const Key('player-synopsis'), + maxLines: 1, + style: _lineStyle, + ); + } + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _expanded = true), + child: Row( + key: const Key('player-synopsis-collapsed'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text.rich( + span, + key: const Key('player-synopsis'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _lineStyle, + ), + ), + const SizedBox(width: 2), + _toggle(widget.l10n.expandSynopsis, + padding: const EdgeInsets.only(left: 6)), + ], + ), + ); } - Widget _collapsedBlock(String label, double maxWidth) { - final link = widget.l10n.expandSynopsis; - final body = _collapsedBody(label, maxWidth, link); - final text = _synopsisText(label, body, link: null, expanded: false); - if (body == _synopsis) return text; - final caret = _caret(label, body, maxWidth); - return Stack( - clipBehavior: Clip.none, + Widget _expandedBlock(InlineSpan span) { + final expandedSpan = TextSpan( + style: _lineStyle, children: [ - text, - Positioned( - left: caret.dx, - top: caret.dy, - child: Padding( - key: const Key('player-synopsis-gap'), - padding: const EdgeInsets.only(left: _expandGap), - child: _toggle(link), + span, + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _toggle( + widget.l10n.showLess, + padding: const EdgeInsets.only(left: 8), ), ), ], ); - } - - Widget _expandedBlock(String label, double maxWidth) { - return SizedBox( - width: maxWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _synopsisText(label, _synopsis, link: null, expanded: true), - Align( - alignment: Alignment.centerRight, - child: _toggle(widget.l10n.showLess), - ), - if (widget.authorNote.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - widget.authorNote, - key: const Key('player-author-note'), - maxLines: _collapsedMaxLines, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.playerMuted, - fontSize: 10, - shadows: _playerTextShadows, - ), + return Column( + key: const Key('player-synopsis-expanded'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich(expandedSpan, key: const Key('player-synopsis')), + if (widget.authorNote.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + widget.authorNote, + key: const Key('player-author-note'), + maxLines: _collapsedMaxLines, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.playerMuted, + fontSize: 10, + shadows: _playerTextShadows, ), - ], + ), ], - ), + ], ); } - Widget _synopsisText( - String label, - String body, { - required String? link, - required bool expanded, - }) { - return Text.rich( - TextSpan( - style: _lineStyle, - children: _spans(label, body, link), - ), - key: const Key('player-synopsis'), - style: _lineStyle, - maxLines: expanded ? null : _collapsedMaxLines, - overflow: expanded ? TextOverflow.clip : TextOverflow.ellipsis, - ); - } - - List _spans(String label, String body, String? link) { - return [ - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Text( - label, - key: const Key('player-episode-label'), - style: _lineStyle, + /// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。 + /// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。 + TextSpan _separatorSpan() => TextSpan( + text: ' | ', + style: _lineStyle.copyWith( + color: AppColors.onPrimary.withValues(alpha: 0.45), + fontWeight: FontWeight.w400, ), - ), - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: _separatorGap), - child: Text( - '|', - style: _lineStyle.copyWith( - color: AppColors.playerMuted.withValues(alpha: 0.6), - ), - ), - ), - ), - TextSpan(text: body, style: _lineStyle), - if (link != null) - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Padding( - key: const Key('player-synopsis-gap'), - padding: const EdgeInsets.only(left: _expandGap), - child: _toggle(link), - ), - ), - ]; - } + ); - String _collapsedBody(String label, double maxWidth, String link) { - if (_fits(label, _synopsis, maxWidth, reserve: 0)) { - return _synopsis; - } - final reserve = - _textWidth(link, _linkStyle) + _expandGap + _expandTrailing; - var low = 0; - var high = _synopsis.length; - var best = '…'; - while (low <= high) { - final mid = (low + high) >> 1; - final slice = '${_synopsis.substring(0, mid)}…'; - if (_fits(label, slice, maxWidth, reserve: reserve)) { - best = slice; - low = mid + 1; - } else { - high = mid - 1; - } - } - return best; - } - - bool _fits( - String label, - String body, - double maxWidth, { - required double reserve, - }) { - final painter = TextPainter( - text: TextSpan( - children: [ - TextSpan(text: label, style: _lineStyle), - const WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: SizedBox(width: _separatorGap), - ), - TextSpan( - text: '|', - style: _lineStyle.copyWith( - color: AppColors.playerMuted.withValues(alpha: 0.6), - ), - ), - const WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: SizedBox(width: _separatorGap), - ), - TextSpan(text: body, style: _lineStyle), - if (reserve > 0) - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: SizedBox(width: reserve), - ), - ], - ), - maxLines: _collapsedMaxLines, - textDirection: TextDirection.ltr, - ); - painter.setPlaceholderDimensions(_slots(reserve)); - painter.layout(maxWidth: maxWidth); - return !painter.didExceedMaxLines; - } - - List _slots(double reserve) { - return [ - const PlaceholderDimensions( - size: Size(_separatorGap, 1), - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - ), - const PlaceholderDimensions( - size: Size(_separatorGap, 1), - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - ), - if (reserve > 0) - PlaceholderDimensions( - size: Size(reserve, _lineStyle.fontSize ?? 12), - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - ), - ]; - } - - Offset _caret(String label, String body, double maxWidth) { - final painter = TextPainter( - text: TextSpan(children: _spans(label, body, null)), - maxLines: _collapsedMaxLines, - textDirection: TextDirection.ltr, - ); - painter.setPlaceholderDimensions(_slots(0)); - painter.layout(maxWidth: maxWidth); - return painter.getOffsetForCaret( - TextPosition(offset: painter.plainText.length), - Rect.zero, - ); - } - double _textWidth(String text, TextStyle style) { - final painter = TextPainter( - text: TextSpan(text: text, style: style), - maxLines: 1, - textDirection: TextDirection.ltr, - )..layout(); - return painter.width; - } - - Widget _toggle(String label) { - return InkWell( + Widget _toggle(String label, {required EdgeInsets padding}) { + return GestureDetector( key: const Key('player-synopsis-toggle'), + behavior: HitTestBehavior.opaque, onTap: () => setState(() => _expanded = !_expanded), - borderRadius: BorderRadius.circular(4), - child: Text(label, maxLines: 1, style: _linkStyle), + child: Padding( + padding: padding, + child: Text(label, maxLines: 1, style: _linkStyle), + ), ); } } diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 4cf3c56..e731d2b 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -53,12 +53,16 @@ class PlayerRenderStack extends StatefulWidget { required this.pageController, required this.slots, this.onCurrentVideoVisible, + this.resumeSignal = 0, }); final PageController pageController; final Map slots; final ValueChanged? onCurrentVideoVisible; + /// iOS 画中画还原的恢复信号,透传给每个槽位的视频表面。 + final int resumeSignal; + @override State createState() => _PlayerRenderStackState(); } @@ -200,6 +204,7 @@ class _PlayerRenderStackState extends State { onVideoVisible: slot.role == PlayerRenderSlotRole.current ? () => widget.onCurrentVideoVisible?.call(slot.index) : null, + resumeSignal: widget.resumeSignal, ), ), ); diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 49f52f6..614215f 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -29,6 +29,7 @@ class PlayerVideoSurface extends StatefulWidget { this.debugIndex, this.showFallbackBackground = true, this.onVideoVisible, + this.resumeSignal = 0, }); final VideoPlayerController? controller; @@ -60,6 +61,10 @@ class PlayerVideoSurface extends StatefulWidget { final bool theater; final VoidCallback? onVideoVisible; + /// iOS 画中画还原等场景的恢复信号:数值变化时回退到占位并重新等首帧, + /// 避免纹理被系统回收后显示黑屏(封面占位 → 新帧淡入)。 + final int resumeSignal; + @override State createState() => _PlayerVideoSurfaceState(); } @@ -76,6 +81,11 @@ class _PlayerVideoSurfaceState extends State bool _transitioning = false; bool _videoVisible = false; bool _videoVisibleNotified = false; + /// 清晰度切换时新源 seek 后的进度;position 越过它才认定纹理已有画面。 + Duration? _confirmBasePosition; + /// 画中画还原等场景的首帧确认下限时刻:position 可能一拍就过基准, + /// 但纹理出帧滞后于进度观测,淡入前强制等待一小段真实解码时间。 + DateTime? _confirmNotBefore; String? _lastLayerState; @override @@ -99,6 +109,13 @@ class _PlayerVideoSurfaceState extends State @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.resumeSignal != widget.resumeSignal) { + _logSlot('pip-resume-reset', widget.controller?.value); + _resetForControllerChange(); + _confirmNotBefore = DateTime.now().add(const Duration(milliseconds: 450)); + setState(() {}); + if (oldWidget.controller == widget.controller) return; + } if (oldWidget.controller != widget.controller) { _logSlot('controller-change-before', oldWidget.controller?.value); _detachController(oldWidget.controller); @@ -261,6 +278,16 @@ class _PlayerVideoSurfaceState extends State return false; } if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true; + // 清晰度切换:新源 seek 完成后 position 立即大于 0,但不代表纹理已有 + // 画面;等 position 越过切换时刻的值(解码循环已在出帧)再确认,避免闪黑。 + final base = _confirmBasePosition; + if (base != null) { + final notBefore = _confirmNotBefore; + if (notBefore != null && DateTime.now().isBefore(notBefore)) { + return false; + } + return value.position > base || value.isCompleted; + } if (value.position > Duration.zero || value.isCompleted) return true; return _playbackStarted && !value.isPlaying && !value.isBuffering; } @@ -321,6 +348,8 @@ class _PlayerVideoSurfaceState extends State _videoVisible = false; _videoVisibleNotified = false; _lastLayerState = null; + // 记录新源切换时刻的进度,供首帧确认判断 position 是否已前进。 + _confirmBasePosition = widget.controller?.value.position; } void _notifyVideoVisible() {