From d13b601ae86686cde8ce23b5c6ef3b9bdeeddae0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 09:16:37 +0800 Subject: [PATCH 01/49] =?UTF-8?q?chore(config):=20=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=9C=B0=E5=9D=80=E6=94=B9=E5=9B=9E=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=2010.39.237.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 6 ++--- test/config/server_endpoint_test.dart | 39 ++++++++++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 98baefc..2d1c8c7 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -11,10 +11,10 @@ import 'package:flutter/foundation.dart'; class AppConfig { const AppConfig._(); - /// 开发调试默认服务地址,当前直接指向公网。 + /// 开发调试默认服务地址,当前指向本机局域网 IP。 /// - /// 需要切换局域网联调时只改这一行,地址不要带结尾斜杠。 - static const String devHostBaseUrl = 'http://39.162.50.99:8081'; + /// 换网络时只改这一行,地址不要带结尾斜杠。 + static const String devHostBaseUrl = 'http://10.39.237.1:8081'; /// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, /// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 524eede..59beeb8 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -62,8 +62,8 @@ void main() { ); }); - test('should_use_the_configured_public_address_for_device_default', () { - expect(AppConfig.devHostBaseUrl, 'http://39.162.50.99:8081'); + test('should_use_the_configured_local_address_for_device_default', () { + expect(AppConfig.devHostBaseUrl, 'http://10.39.237.1:8081'); }); test('should_skip_probe_when_compiled_override_is_set', () async { @@ -111,7 +111,11 @@ void main() { }, ); - expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]); + expect(attempts, [ + AppConfig.lanBaseUrl, + AppConfig.devHostBaseUrl, + AppConfig.publicBaseUrl, + ]); expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl); }); @@ -129,7 +133,7 @@ void main() { attempts.add(baseUrl); active++; if (active > maxActive) maxActive = active; - if (active == 2) allStarted.complete(); + if (active == 3) allStarted.complete(); await allStarted.future; active--; return baseUrl == AppConfig.lanBaseUrl || @@ -139,8 +143,12 @@ void main() { await probing.timeout(const Duration(seconds: 1)); - expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]); - expect(maxActive, 2); + expect(attempts, [ + AppConfig.lanBaseUrl, + AppConfig.devHostBaseUrl, + AppConfig.publicBaseUrl, + ]); + expect(maxActive, 3); expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl); }, ); @@ -156,7 +164,11 @@ void main() { }, ); - expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]); + expect(attempts, [ + AppConfig.lanBaseUrl, + AppConfig.devHostBaseUrl, + AppConfig.publicBaseUrl, + ]); expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl); }); @@ -171,7 +183,11 @@ void main() { }, ); - expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]); + expect(attempts, [ + AppConfig.lanBaseUrl, + AppConfig.devHostBaseUrl, + AppConfig.publicBaseUrl, + ]); expect(AppConfig.apiBaseUrl, AppConfig.devHostBaseUrl); }); @@ -187,7 +203,12 @@ void main() { }, ); - expect(attempts, [custom, AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]); + expect(attempts, [ + custom, + AppConfig.lanBaseUrl, + AppConfig.devHostBaseUrl, + AppConfig.publicBaseUrl, + ]); expect(AppConfig.apiBaseUrl, custom); }); From 8c04099d975fff7a33486634ffc013713de7c9eb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 09:23:30 +0800 Subject: [PATCH 02/49] =?UTF-8?q?feat(player):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E8=BF=9B=E8=AF=A6=E6=83=85=E4=B8=8E=E6=92=AD=E6=94=BE=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=B7=A1=E5=85=A5=E8=BD=AC=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/navigation/fade_page_route.dart | 17 ++++++++ lib/pages/player/open_player.dart | 3 +- lib/pages/player/player_logic.dart | 5 ++- test/navigation/fade_page_route_test.dart | 50 +++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 lib/core/navigation/fade_page_route.dart create mode 100644 test/navigation/fade_page_route_test.dart diff --git a/lib/core/navigation/fade_page_route.dart b/lib/core/navigation/fade_page_route.dart new file mode 100644 index 0000000..8b1c0fd --- /dev/null +++ b/lib/core/navigation/fade_page_route.dart @@ -0,0 +1,17 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; + +/// 无位移的短促淡入转场。 +class FadePageRoute extends PageRouteBuilder { + FadePageRoute({required WidgetBuilder builder, super.settings}) + : super( + transitionDuration: const Duration(milliseconds: 220), + reverseTransitionDuration: const Duration(milliseconds: 180), + pageBuilder: (context, _, _) => builder(context), + transitionsBuilder: (context, animation, _, child) => FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); +} diff --git a/lib/pages/player/open_player.dart b/lib/pages/player/open_player.dart index af7ea66..9d2ac32 100644 --- a/lib/pages/player/open_player.dart +++ b/lib/pages/player/open_player.dart @@ -7,6 +7,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/wallet_controller.dart'; +import '../../core/navigation/fade_page_route.dart'; import 'player_view.dart'; /// 目录卡片统一入口:补齐详情后直接进入全屏播放页。 @@ -29,7 +30,7 @@ Future playDramaFromCatalog( ? 0 : playable.episodes.indexWhere((episode) => episode.id == episodeId); await Navigator.of(context).push( - MaterialPageRoute( + FadePageRoute( builder: (_) => PlayerPage( drama: playable, initialIndex: index < 0 ? 0 : index, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 2ab8cca..1f887e5 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -21,6 +21,7 @@ import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/navigation/fade_page_route.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; @@ -625,7 +626,7 @@ class PlayerLogic extends ChangeNotifier { try { if (!context.mounted) return; await Navigator.of(context).push( - MaterialPageRoute( + FadePageRoute( builder: (_) => DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama), ), @@ -646,7 +647,7 @@ class PlayerLogic extends ChangeNotifier { await saveCurrentProgress(); if (!context.mounted) return; await Navigator.of(context).push( - MaterialPageRoute( + FadePageRoute( builder: (_) => PlayerPage( drama: drama, initialIndex: index, diff --git a/test/navigation/fade_page_route_test.dart b/test/navigation/fade_page_route_test.dart new file mode 100644 index 0000000..739e16d --- /dev/null +++ b/test/navigation/fade_page_route_test.dart @@ -0,0 +1,50 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/navigation/fade_page_route.dart'; + +void main() { + testWidgets('should_fade_in_without_slide_and_use_short_transitions', ( + tester, + ) async { + final navigatorKey = GlobalKey(); + var routePushed = false; + final route = FadePageRoute( + builder: (_) => + const Scaffold(key: Key('fade-route-page'), body: SizedBox.expand()), + ); + + expect(route.transitionDuration, const Duration(milliseconds: 220)); + expect(route.reverseTransitionDuration, const Duration(milliseconds: 180)); + expect(route.opaque, isTrue); + + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () { + navigatorKey.currentState!.push(route); + routePushed = true; + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(routePushed, isTrue); + expect(find.byKey(const Key('fade-route-page')), findsOneWidget); + final fade = tester.widget( + find.byType(FadeTransition).last, + ); + expect(fade.opacity.value, inExclusiveRange(0, 1)); + }); +} From 99b45e4f12fb9f2f76a177363effd125d07eef43 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 09:57:58 +0800 Subject: [PATCH 03/49] =?UTF-8?q?chore(config):=20=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=9C=B0=E5=9D=80=E6=94=B9=E5=9B=9E=20192.16?= =?UTF-8?q?8.31.187?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 2 +- test/config/server_endpoint_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 2d1c8c7..145f41c 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -14,7 +14,7 @@ class AppConfig { /// 开发调试默认服务地址,当前指向本机局域网 IP。 /// /// 换网络时只改这一行,地址不要带结尾斜杠。 - static const String devHostBaseUrl = 'http://10.39.237.1:8081'; + static const String devHostBaseUrl = 'http://192.168.31.187:8081'; /// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, /// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 59beeb8..6ecaee2 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -63,7 +63,7 @@ void main() { }); test('should_use_the_configured_local_address_for_device_default', () { - expect(AppConfig.devHostBaseUrl, 'http://10.39.237.1:8081'); + expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081'); }); test('should_skip_probe_when_compiled_override_is_set', () async { From cf4b07aa6980ece0b6fb52ac058990d33c4fdb34 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 10:07:34 +0800 Subject: [PATCH 04/49] =?UTF-8?q?feat(player):=20=E6=9C=AA=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA=E6=94=B9=E7=94=B1=E8=BF=9B=E5=BA=A6=E6=9D=A1=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=8A=A0=E8=BD=BD=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 1 - .../player/widgets/home_feed_overlay.dart | 1 + .../widgets/home_feed_overlay_parts.dart | 22 ++- .../widgets/loading_progress_track.dart | 102 ++++++++++++++ .../player/widgets/player_video_surface.dart | 43 +----- .../player/widgets/seek_progress_bar.dart | 97 +++++++------ test/home/home_preview_layout_test.dart | 2 + test/playback/seek_progress_bar_test.dart | 130 +++++++++++++----- test/player/loading_progress_track_test.dart | 53 +++++++ test/player/player_fullscreen_test.dart | 54 ++------ test/player/player_video_surface_test.dart | 18 ++- test/widget_test.dart | 3 +- 12 files changed, 359 insertions(+), 167 deletions(-) create mode 100644 lib/pages/player/widgets/loading_progress_track.dart create mode 100644 test/player/loading_progress_track_test.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 2bbdb2d..96c5af8 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -319,7 +319,6 @@ class _PlayerPageState extends State ), PlayerVideoSurface( controller: videoPort?.controller, - posterUrl: drama.posterUrl, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, onTap: () => diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 35a3374..a4f2d75 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -10,6 +10,7 @@ import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import 'interaction_rail.dart'; +import 'loading_progress_track.dart'; part 'home_feed_overlay_parts.dart'; diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index c5cc0fe..c7b8709 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -37,10 +37,13 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final width = constraints.maxWidth; final source = widget.controller; final track = source == null - ? _track(0, width) + ? _loadingTrack() : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { + if (!value.isInitialized || value.isBuffering) { + return _loadingTrack(); + } final total = value.duration.inMilliseconds; final played = total <= 0 ? 0.0 @@ -67,6 +70,23 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ); } + Widget _loadingTrack() { + final bottom = _lineCenterFromBottom - widget.height / 2; + return SizedBox( + height: widget.hitHeight, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: bottom, + child: LoadingProgressTrack(height: widget.height), + ), + ], + ), + ); + } + void _update(double fraction) { setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); } diff --git a/lib/pages/player/widgets/loading_progress_track.dart b/lib/pages/player/widgets/loading_progress_track.dart new file mode 100644 index 0000000..5d48d51 --- /dev/null +++ b/lib/pages/player/widgets/loading_progress_track.dart @@ -0,0 +1,102 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// 播放未就绪时沿进度轨道循环移动的加载高光。 +class LoadingProgressTrack extends StatefulWidget { + const LoadingProgressTrack({ + super.key = const Key('progress-loading'), + required this.height, + this.trackColor = Colors.white24, + this.highlightColor = AppColors.blue, + }); + + final double height; + final Color trackColor; + final Color highlightColor; + + @override + State createState() => _LoadingProgressTrackState(); +} + +class _LoadingProgressTrackState extends State + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + late final AnimationController _controller; + late final Animation _position; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _position = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); + WidgetsBinding.instance.addObserver(this); + _syncAnimation(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + _syncAnimation(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller.dispose(); + super.dispose(); + } + + void _syncAnimation() { + if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { + if (!_controller.isAnimating) _controller.repeat(); + } else { + _controller.stop(); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: ClipRRect( + borderRadius: BorderRadius.circular(widget.height / 2), + child: LayoutBuilder( + builder: (context, constraints) { + final highlightWidth = constraints.maxWidth * 0.28; + final travel = constraints.maxWidth + highlightWidth; + return AnimatedBuilder( + animation: _position, + builder: (context, _) { + final left = -highlightWidth + travel * _position.value; + return Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + key: const Key('progress-loading-track'), + color: widget.trackColor, + ), + Positioned( + left: left, + top: 0, + bottom: 0, + width: highlightWidth, + child: ColoredBox( + key: const Key('progress-loading-highlight'), + color: widget.highlightColor, + ), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index cdd66c3..1226e3f 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -4,25 +4,21 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; -import '../../../core/widgets/remote_image.dart'; - const Duration _videoFadeDuration = Duration(milliseconds: 200); const double _errorHorizontal = 32; const int _errorMaxLines = 3; -/// 封面底层与视频层共用同一控制器,视频首帧就绪后再淡入。 +/// 黑色底层与视频层共用同一控制器,视频首帧就绪后再淡入。 class PlayerVideoSurface extends StatefulWidget { const PlayerVideoSurface({ super.key, required this.controller, - required this.posterUrl, required this.videoUrl, required this.theater, required this.onTap, }); final VideoPlayerController? controller; - final String posterUrl; final String videoUrl; final bool theater; final VoidCallback onTap; @@ -57,7 +53,10 @@ class _PlayerVideoSurfaceState extends State { return Stack( fit: StackFit.expand, children: [ - _FallbackBackground(posterUrl: widget.posterUrl), + const ColoredBox( + key: Key('player-fallback-background'), + color: Colors.black, + ), AnimatedOpacity( key: const Key('player-video-opacity'), opacity: reveal ? 1 : 0, @@ -164,35 +163,3 @@ class _VideoLayer extends StatelessWidget { ); } } - -class _FallbackBackground extends StatelessWidget { - const _FallbackBackground({required this.posterUrl}); - - final String posterUrl; - - @override - Widget build(BuildContext context) { - if (posterUrl.trim().isEmpty) { - return const ColoredBox( - key: Key('player-fallback-background'), - color: Colors.black, - ); - } - return Stack( - key: const Key('player-fallback-background'), - fit: StackFit.expand, - children: [ - RemoteImage( - posterUrl, - key: const Key('player-fallback-cover'), - fit: BoxFit.cover, - fallbackColor: Colors.black, - ), - const ColoredBox( - key: Key('player-fallback-scrim'), - color: Color(0x4D000000), - ), - ], - ); - } -} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index faa2e85..fa7e8bd 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; +import 'loading_progress_track.dart'; /// Keeps seeks within the playable range so dragging to the edge does not /// trigger the player's completion callback before playback resumes. @@ -92,6 +93,7 @@ class _SeekProgressBarState extends State { Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; + final loading = !video.isInitialized || video.isBuffering; final enabled = video.isInitialized && total > 0 && widget.onSeek != null; final played = total > 0 ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) @@ -100,7 +102,15 @@ class _SeekProgressBarState extends State { final previewPosition = _previewFraction.value == null ? video.position : seekPositionForFraction(fraction, video.duration); - final scrubbing = _previewFraction.value != null; + final scrubbing = !loading && _previewFraction.value != null; + final trackHeight = widget.compact + ? (scrubbing ? 3.0 : 1.5) + : (scrubbing ? 4.0 : 3.0); + final trackAreaHeight = loading + ? (widget.compact ? 20.0 : 44.0) + : widget.compact + ? (scrubbing ? 32.0 : 20.0) + : (scrubbing ? 48.0 : 44.0); return Column( mainAxisSize: MainAxisSize.min, @@ -120,47 +130,50 @@ class _SeekProgressBarState extends State { ), ), SizedBox( - height: widget.compact - ? (scrubbing ? 32 : 20) - : (scrubbing ? 48 : 44), - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: widget.compact - ? (scrubbing ? 3 : 1.5) - : (scrubbing ? 4 : 3), - activeTrackColor: AppColors.blue, - inactiveTrackColor: widget.inactiveTrackColor, - thumbColor: AppColors.onPrimary, - thumbShape: RoundSliderThumbShape( - enabledThumbRadius: widget.compact - ? (scrubbing ? 6 : 4) - : (scrubbing ? 8 : 6), - ), - overlayShape: RoundSliderOverlayShape( - overlayRadius: widget.compact ? 12 : 18, - ), - ), - child: Semantics( - label: widget.semanticsLabel, - child: Slider( - value: fraction, - // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 - padding: EdgeInsets.zero, - onChangeStart: enabled - ? (value) { - _previewFraction.value = value; - widget.onScrubStart?.call(); - } - : null, - onChanged: enabled - ? (value) => _previewFraction.value = value - : null, - onChangeEnd: enabled - ? (value) => unawaited(_finish(value, video.duration)) - : null, - ), - ), - ), + height: trackAreaHeight, + child: loading + ? Center( + child: LoadingProgressTrack( + height: trackHeight, + trackColor: widget.inactiveTrackColor, + ), + ) + : SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: trackHeight, + activeTrackColor: AppColors.blue, + inactiveTrackColor: widget.inactiveTrackColor, + thumbColor: AppColors.onPrimary, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: widget.compact + ? (scrubbing ? 6 : 4) + : (scrubbing ? 8 : 6), + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: widget.compact ? 12 : 18, + ), + ), + child: Semantics( + label: widget.semanticsLabel, + child: Slider( + value: fraction, + // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 + padding: EdgeInsets.zero, + onChangeStart: enabled + ? (value) { + _previewFraction.value = value; + widget.onScrubStart?.call(); + } + : null, + onChanged: enabled + ? (value) => _previewFraction.value = value + : null, + onChangeEnd: enabled + ? (value) => unawaited(_finish(value, video.duration)) + : null, + ), + ), + ), ), ], ); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 17224fc..704dc0d 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -150,6 +150,8 @@ void main() { tester.widget(find.byKey(const Key('rail-comments-count'))).data, '12', ); + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); }); testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async { diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 53ad74b..eb8524f 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -16,55 +16,111 @@ void main() { expect(seekPositionForFraction(1, duration), lessThan(duration)); }); - testWidgets('should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', ( + testWidgets('should_show_loading_track_when_controller_is_missing', ( tester, ) async { - final video = ValueNotifier( - const VideoPlayerValue( - duration: Duration(seconds: 12), - position: Duration(seconds: 3), - isInitialized: true, - ), - ); - addTearDown(video.dispose); - var started = 0; - Duration? target; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: SizedBox( - width: 300, - child: SeekProgressBar( - valueListenable: video, - semanticsLabel: 'Seek video', - onScrubStart: () => started++, - onSeek: (position) async { - target = position; - }, - ), - ), + body: SeekProgressBar( + valueListenable: null, + semanticsLabel: 'Seek video', ), ), ), ); - final gesture = await tester.startGesture( - tester.getCenter(find.byType(Slider)), - ); - await gesture.moveBy(const Offset(120, 0)); - await tester.pump(); - expect(started, 1); - expect(target, isNull); - expect(find.textContaining('/ 00:12'), findsOneWidget); - await gesture.up(); - await tester.pumpAndSettle(); - expect(target, isNotNull); - expect(target!, greaterThan(const Duration(seconds: 3))); - expect(target!, lessThan(const Duration(seconds: 12))); - expect(find.textContaining('/ 00:12'), findsNothing); + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byType(Slider), findsNothing); }); + testWidgets('should_switch_between_loading_track_and_slider', (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + isBuffering: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onSeek: (_) async {}, + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byType(Slider), findsNothing); + expect(find.textContaining('00:12'), findsNothing); + + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ); + await tester.pump(); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets( + 'should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', + (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ), + ); + addTearDown(video.dispose); + var started = 0; + Duration? target; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onScrubStart: () => started++, + onSeek: (position) async { + target = position; + }, + ), + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(120, 0)); + await tester.pump(); + expect(started, 1); + expect(target, isNull); + expect(find.textContaining('/ 00:12'), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + expect(target, isNotNull); + expect(target!, greaterThan(const Duration(seconds: 3))); + expect(target!, lessThan(const Duration(seconds: 12))); + expect(find.textContaining('/ 00:12'), findsNothing); + }, + ); + testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', ( tester, ) async { diff --git a/test/player/loading_progress_track_test.dart b/test/player/loading_progress_track_test.dart new file mode 100644 index 0000000..cacf042 --- /dev/null +++ b/test/player/loading_progress_track_test.dart @@ -0,0 +1,53 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/pages/player/widgets/loading_progress_track.dart'; + +void main() { + testWidgets('should_move_highlight_while_loading', (tester) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + addTearDown( + () => tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.paused, + ), + ); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox(width: 240, child: LoadingProgressTrack(height: 4)), + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('progress-loading-track'))) + .color, + Colors.white24, + ); + expect( + tester + .widget( + find.byKey(const Key('progress-loading-highlight')), + ) + .color, + AppColors.blue, + ); + + final initialLeft = tester + .getTopLeft(find.byKey(const Key('progress-loading-highlight'))) + .dx; + await tester.pump(const Duration(milliseconds: 300)); + final movedLeft = tester + .getTopLeft(find.byKey(const Key('progress-loading-highlight'))) + .dx; + expect(movedLeft, greaterThan(initialLeft)); + expect(tester.getSize(find.byKey(const Key('progress-loading'))).height, 4); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 29f26f2..d8a7b6b 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -18,7 +18,6 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; -import 'package:fruitshorts/core/widgets/remote_image.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; @@ -132,15 +131,9 @@ void main() { expect(player.playing, isTrue); }); - testWidgets('should_fall_back_to_black_when_poster_is_empty', (tester) async { - await _pumpApp( - tester, - catalog: FakeCatalogRepository(dramas: const [_noCoverDrama]), - ); - await _openSeriesPlayer( - tester, - cardKey: const Key('home-drama-card-no-cover-demo'), - ); + testWidgets('should_keep_video_surface_background_black', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); expect( tester @@ -150,6 +143,8 @@ void main() { .color, Colors.black, ); + expect(find.byKey(const Key('player-fallback-cover')), findsNothing); + expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); }); testWidgets('should_gate_comments_before_opening_sheet_when_signed_out', ( @@ -415,19 +410,9 @@ void main() { expect(find.byKey(const Key('player-background')), findsNothing); final fallback = find.byKey(const Key('player-fallback-background')); expect(fallback, findsOneWidget); - expect(find.byKey(const Key('player-fallback-cover')), findsOneWidget); - expect( - tester - .widget(find.byKey(const Key('player-fallback-cover'))) - .fallbackColor, - Colors.black, - ); - expect( - tester - .widget(find.byKey(const Key('player-fallback-scrim'))) - .color, - const Color(0x4D000000), - ); + expect(tester.widget(fallback).color, Colors.black); + expect(find.byKey(const Key('player-fallback-cover')), findsNothing); + expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); expect( tester .widget(find.byKey(const Key('player-scaffold'))) @@ -476,7 +461,8 @@ void main() { tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 40), ); - expect(tester.widget(find.byType(Slider)).padding, EdgeInsets.zero); + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byType(Slider), findsNothing); expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); @@ -1092,7 +1078,8 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-pip')), findsNothing); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing); - expect(find.byType(Slider), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byType(Slider), findsNothing); expect(find.byKey(const Key('player-title')), findsNothing); expect(find.byKey(const Key('player-subtitle')), findsNothing); expect(find.byKey(const Key('player-genre-chip')), findsNothing); @@ -1211,20 +1198,3 @@ const _lockedDrama = Drama( ), ], ); - -const _noCoverDrama = Drama( - id: 'no-cover-demo', - title: 'No Cover Demo', - synopsis: 'No cover synopsis', - posterUrl: '', - detailLoaded: true, - episodes: [ - Episode( - id: 'no-cover-demo-1', - number: 1, - title: 'No Cover Episode', - isFree: true, - videoUrl: 'https://example.test/no-cover-demo-1.mp4', - ), - ], -); diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 3cc3194..7ab4f3c 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -12,8 +12,9 @@ void main() { ) async { await _pumpSurface(tester); - expect(find.byKey(const Key('player-fallback-background')), findsOneWidget); - expect(find.byKey(const Key('player-fallback-cover')), findsOneWidget); + expect(_fallbackColor(tester), Colors.black); + expect(find.byKey(const Key('player-fallback-cover')), findsNothing); + expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); expect(_opacity(tester), 0); expect(find.byKey(const Key('player-debug-video')), findsNothing); }); @@ -48,7 +49,8 @@ void main() { expect(halfway, lessThan(1)); await tester.pump(const Duration(milliseconds: 100)); expect(_fadeValue(tester), 1); - expect(find.byKey(const Key('player-fallback-background')), findsOneWidget); + expect(_fallbackColor(tester), Colors.black); + expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(find.byKey(const Key('player-debug-video')), findsNothing); }); @@ -62,7 +64,8 @@ void main() { expect(find.byKey(const Key('player-video-error')), findsOneWidget); expect(find.text('No route to host'), findsOneWidget); - expect(find.byKey(const Key('player-fallback-background')), findsOneWidget); + expect(_fallbackColor(tester), Colors.black); + expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(_opacity(tester), 0); expect(find.byKey(const Key('player-debug-video')), findsNothing); }); @@ -82,7 +85,6 @@ Future _pumpSurface( home: Scaffold( body: PlayerVideoSurface( controller: controller, - posterUrl: '/files/poster.jpg', videoUrl: controller.dataSource, theater: false, onTap: () {}, @@ -94,6 +96,12 @@ Future _pumpSurface( return controller; } +Color _fallbackColor(WidgetTester tester) { + return tester + .widget(find.byKey(const Key('player-fallback-background'))) + .color; +} + double _opacity(WidgetTester tester) { return tester .widget(find.byKey(const Key('player-video-opacity'))) diff --git a/test/widget_test.dart b/test/widget_test.dart index 5b910bc..27b9260 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -185,7 +185,8 @@ void main() { await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - expect(find.byType(Slider), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byType(Slider), findsNothing); await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.pumpAndSettle(); expect( From d6b2d308ebc890434535a8d58dbb08cda81599a2 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 10:16:16 +0800 Subject: [PATCH 05/49] =?UTF-8?q?feat(nav):=20=E5=BA=95=E9=83=A8=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E5=9B=BE=E6=A0=87=E4=B8=8E=E6=96=87=E5=AD=97=E6=94=BE?= =?UTF-8?q?=E5=A4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_dimens.dart | 6 +++--- lib/core/widgets/app_bottom_nav.dart | 2 +- test/theme/app_dimens_test.dart | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 6808229..828b371 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -34,7 +34,7 @@ abstract final class AppDimens { static const double previewCardTitle = 13; static const double previewSectionTitle = 14; static const double chipFont = 10; - static const double navLabel = 10; + static const double navLabel = 12; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; @@ -45,8 +45,8 @@ abstract final class AppDimens { static const double tapTarget = 44; static const double contentHeaderHeight = 46; - static const double navHeight = 64; - static const double navIcon = 20; + static const double navHeight = 68; + static const double navIcon = 24; static const double progressHeight = 3; static const double progressRadius = 2; static const double progressLineHeight = 3; diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 9081ebb..102feb7 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -107,7 +107,7 @@ class _NavDestination extends StatelessWidget { color: item.selected ? AppColors.blue : unselectedColor, ), ), - const SizedBox(height: 2), + const SizedBox(height: 3), Text( item.label, maxLines: 1, diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index 87b1073..4051eb1 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -16,11 +16,12 @@ void main() { expect(AppDimens.listTitle, 15); expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); - expect(AppDimens.navLabel, 10); + expect(AppDimens.navLabel, 12); + expect(AppDimens.navIcon, 24); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); - expect(AppDimens.navHeight, 64); + expect(AppDimens.navHeight, 68); expect(AppDimens.progressHeight, 3); }); } From 1993ff110298b2f4e6ae595b81998556d2de99ab Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 10:24:34 +0800 Subject: [PATCH 06/49] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=97=B6=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=80=81=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/home_feed_overlay.dart | 2 + .../widgets/home_feed_overlay_parts.dart | 111 ++++++++++++++++-- .../player/widgets/seek_progress_bar.dart | 76 +++++++++++- test/home/home_preview_layout_test.dart | 105 +++++++++++++++++ test/playback/seek_progress_bar_test.dart | 69 ++++++++++- 5 files changed, 350 insertions(+), 13 deletions(-) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index a4f2d75..fb71a6b 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index c7b8709..690876b 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -28,7 +28,36 @@ class _FeedProgressBar extends StatefulWidget { } class _FeedProgressBarState extends State<_FeedProgressBar> { + static const _bufferDebounce = Duration(milliseconds: 350); + double? _preview; + Timer? _bufferTimer; + bool _buffering = false; + + @override + void initState() { + super.initState(); + _attachSource(); + } + + @override + void didUpdateWidget(covariant _FeedProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _detachSource(oldWidget.controller); + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + _attachSource(); + } + } + + @override + void dispose() { + _detachSource(widget.controller); + _bufferTimer?.cancel(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -36,12 +65,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { builder: (context, constraints) { final width = constraints.maxWidth; final source = widget.controller; + final scrubbing = _preview != null; + final loading = + source == null || + (!scrubbing && (!source.value.isInitialized || _buffering)); final track = source == null ? _loadingTrack() : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { - if (!value.isInitialized || value.isBuffering) { + if (!scrubbing && (!value.isInitialized || _buffering)) { return _loadingTrack(); } final total = value.duration.inMilliseconds; @@ -56,20 +89,80 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (details) { - widget.onScrubStart?.call(); - _update(details.localPosition.dx / width); - }, - onHorizontalDragUpdate: (details) => - _update(details.localPosition.dx / width), - onHorizontalDragEnd: (_) => _commit(_preview), - onHorizontalDragCancel: () => setState(() => _preview = null), + onHorizontalDragStart: loading + ? null + : (details) { + widget.onScrubStart?.call(); + _update(details.localPosition.dx / width); + }, + onHorizontalDragUpdate: loading + ? null + : (details) => _update(details.localPosition.dx / width), + onHorizontalDragEnd: loading ? null : (_) => _commit(_preview), + onHorizontalDragCancel: loading + ? null + : () => setState(() => _preview = null), child: track, ); }, ); } + void _attachSource() { + widget.controller?.addListener(_handleSourceChanged); + _syncBufferState(notify: false); + } + + void _detachSource(ValueListenable? source) { + source?.removeListener(_handleSourceChanged); + } + + void _handleSourceChanged() => _syncBufferState(); + + void _syncBufferState({bool notify = true}) { + final source = widget.controller; + if (source == null) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + final value = source.value; + if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(true, notify: notify); + return; + } + if (!value.isBuffering) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + if (_buffering || _bufferTimer != null) return; + _bufferTimer = Timer(_bufferDebounce, () { + _bufferTimer = null; + final current = widget.controller; + if (!mounted || + current != source || + !current!.value.isInitialized || + !current.value.isBuffering) { + return; + } + _setBuffering(true); + }); + } + + void _setBuffering(bool buffering, {bool notify = true}) { + if (_buffering == buffering) return; + if (!notify || !mounted) { + _buffering = buffering; + return; + } + setState(() => _buffering = buffering); + } + Widget _loadingTrack() { final bottom = _lineCenterFromBottom - widget.height / 2; return SizedBox( diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index fa7e8bd..a4fcbb0 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -44,22 +44,94 @@ class SeekProgressBar extends StatefulWidget { } class _SeekProgressBarState extends State { + static const _bufferDebounce = Duration(milliseconds: 350); + final _previewFraction = ValueNotifier(null); + Timer? _bufferTimer; + bool _buffering = false; + + @override + void initState() { + super.initState(); + _attachSource(); + } @override void didUpdateWidget(covariant SeekProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.valueListenable != widget.valueListenable) { + _detachSource(oldWidget.valueListenable); _previewFraction.value = null; + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + _attachSource(); } } @override void dispose() { + _detachSource(widget.valueListenable); + _bufferTimer?.cancel(); _previewFraction.dispose(); super.dispose(); } + void _attachSource() { + widget.valueListenable?.addListener(_handleSourceChanged); + _syncBufferState(notify: false); + } + + void _detachSource(ValueListenable? source) { + source?.removeListener(_handleSourceChanged); + } + + void _handleSourceChanged() => _syncBufferState(); + + void _syncBufferState({bool notify = true}) { + final source = widget.valueListenable; + if (source == null) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + final value = source.value; + if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(true, notify: notify); + return; + } + if (!value.isBuffering) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + if (_buffering || _bufferTimer != null) return; + _bufferTimer = Timer(_bufferDebounce, () { + _bufferTimer = null; + final current = widget.valueListenable; + if (!mounted || + current != source || + !current!.value.isInitialized || + !current.value.isBuffering) { + return; + } + _setBuffering(true); + }); + } + + void _setBuffering(bool buffering, {bool notify = true}) { + if (_buffering == buffering) return; + if (!notify || !mounted) { + _buffering = buffering; + return; + } + setState(() => _buffering = buffering); + } + Future _finish(double fraction, Duration duration) async { final source = widget.valueListenable; _previewFraction.value = fraction; @@ -93,7 +165,8 @@ class _SeekProgressBarState extends State { Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; - final loading = !video.isInitialized || video.isBuffering; + final scrubbing = _previewFraction.value != null; + final loading = !scrubbing && (!video.isInitialized || _buffering); final enabled = video.isInitialized && total > 0 && widget.onSeek != null; final played = total > 0 ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) @@ -102,7 +175,6 @@ class _SeekProgressBarState extends State { final previewPosition = _previewFraction.value == null ? video.position : seekPositionForFraction(fraction, video.duration); - final scrubbing = !loading && _previewFraction.value != null; final trackHeight = widget.compact ? (scrubbing ? 3.0 : 1.5) : (scrubbing ? 4.0 : 3.0); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 704dc0d..5059184 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -291,6 +291,111 @@ void main() { expect(opened, 1); }); + testWidgets('should_debounce_feed_loading_and_keep_track_while_scrubbing', ( + tester, + ) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: const Drama( + id: 'debounce-drama', + title: 'Debounce Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'debounce-episode', + number: 1, + title: 'Episode 1', + isFree: true, + ), + controller: controller, + liked: false, + saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onMore: () {}, + onSeek: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + ); + await tester.pump(); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('home-feed-progress'))), + ); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + testWidgets('should_show_refresh_spinner_until_refresh_finishes', ( tester, ) async { diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index eb8524f..860423f 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -34,7 +34,9 @@ void main() { expect(find.byType(Slider), findsNothing); }); - testWidgets('should_switch_between_loading_track_and_slider', (tester) async { + testWidgets('should_debounce_buffering_before_showing_loading_track', ( + tester, + ) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), @@ -56,9 +58,17 @@ void main() { ), ); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + expect(find.textContaining('00:12'), findsNothing); + + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 200)); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byType(Slider), findsNothing); - expect(find.textContaining('00:12'), findsNothing); video.value = const VideoPlayerValue( duration: Duration(seconds: 12), @@ -71,6 +81,61 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); + testWidgets('should_keep_slider_while_scrubbing_during_buffering', ( + tester, + ) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onSeek: (_) async {}, + ), + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 5), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 5), + isInitialized: true, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + testWidgets( 'should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', (tester) async { From 7fccd167504f5b71afcb28c0fde4639e29f9f5a2 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 10:34:18 +0800 Subject: [PATCH 07/49] =?UTF-8?q?feat(player):=20=E6=9B=B4=E5=A4=9A?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=8C=89=E5=8F=82=E8=80=83=E5=9B=BE=E9=87=8D?= =?UTF-8?q?=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/player_more_sheet.dart | 266 ++++++++++-------- test/player/player_fullscreen_test.dart | 25 +- test/player/player_more_sheet_test.dart | 100 +++++++ 3 files changed, 260 insertions(+), 131 deletions(-) diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 49eb80f..4f93858 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -6,12 +6,21 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; const double _sheetRadius = 20; -const double _rowHeight = 44; +const double _rowHeight = 56; const double _groupGap = 12; -const double _segmentHeight = 28; +const double _groupVerticalPadding = 6; +const double _groupRadius = 16; +const double _rowHorizontalPadding = 14; +const double _rowIconSize = 22; +const double _rowIconGap = 14; +const double _rowLabelSize = 16; +const double _rowChevronGap = 10; +const double _segmentTrackHeight = 34; +const double _segmentHeight = 26; const double _segmentRadius = 8; +const double _segmentTrackRadius = 10; +const double _segmentTrackPadding = 4; const double _segmentPadding = 10; -const double _segmentGap = 6; const List _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; const List _qualityOptions = ['AUTO', '720P', '1080P']; const String _autoQuality = 'AUTO'; @@ -82,7 +91,7 @@ class _PlayerMoreSheetState extends State { final l10n = widget.l10n; return Material( key: const Key('player-more-sheet'), - color: AppColors.card, + color: AppColors.ink, borderRadius: const BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), @@ -90,7 +99,7 @@ class _PlayerMoreSheetState extends State { child: SafeArea( top: false, child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -105,31 +114,30 @@ class _PlayerMoreSheetState extends State { ), ), ), - const SizedBox(height: 8), - _header(l10n), - _speedPicker(), - const SizedBox(height: 4), - _qualityPicker(l10n), + const SizedBox(height: 12), + _group([_speedPicker(), _qualityPicker(l10n)]), const SizedBox(height: _groupGap), - _actionRow( - key: const Key('player-more-clear'), - icon: Icons.fullscreen_rounded, - title: l10n.clearPlayback, - onTap: _clear, - ), - if (widget.actions.onPip != null) + _group([ _actionRow( - key: const Key('player-more-pip'), - icon: Icons.picture_in_picture_alt_rounded, - title: l10n.pictureInPicture, - onTap: () => _closeThen(widget.actions.onPip!), + key: const Key('player-more-clear'), + icon: Icons.fullscreen, + title: l10n.clearPlayback, + onTap: _clear, ), - _actionRow( - key: const Key('player-more-offline'), - icon: Icons.download_rounded, - title: l10n.offlineCache, - onTap: () => _closeThen(widget.actions.onDownload), - ), + if (widget.actions.onPip != null) + _actionRow( + key: const Key('player-more-pip'), + icon: Icons.picture_in_picture_alt_outlined, + title: l10n.pictureInPicture, + onTap: () => _closeThen(widget.actions.onPip!), + ), + _actionRow( + key: const Key('player-more-offline'), + icon: Icons.download_outlined, + title: l10n.offlineCache, + onTap: () => _closeThen(widget.actions.onDownload), + ), + ]), ], ), ), @@ -137,35 +145,21 @@ class _PlayerMoreSheetState extends State { ); } - Widget _header(AppLocalizations l10n) => Row( - children: [ - Expanded( - child: Text( - l10n.more, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), - ), - SizedBox.square( - dimension: 32, - child: IconButton( - key: const Key('player-more-close'), - tooltip: l10n.close, - padding: EdgeInsets.zero, - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close_rounded, size: 18), - ), - ), - ], + Widget _group(List rows) => Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: _groupVerticalPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_groupRadius), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: rows), ); Widget _speedPicker() => _optionRow( rowKey: const Key('player-more-speed-row'), optionsKey: const Key('player-more-speed'), - icon: Icons.speed_rounded, + trackKey: const Key('player-more-speed-track'), + icon: Icons.speed_outlined, title: widget.l10n.playbackSpeed, options: [ for (final value in _speedOptions) @@ -184,7 +178,8 @@ class _PlayerMoreSheetState extends State { Widget _qualityPicker(AppLocalizations l10n) => _optionRow( rowKey: const Key('player-more-quality-row'), optionsKey: const Key('player-more-quality'), - icon: Icons.high_quality_rounded, + trackKey: const Key('player-more-quality-track'), + icon: Icons.high_quality_outlined, title: l10n.playbackQuality, options: [ for (final quality in _qualityOptions) _qualitySegment(l10n, quality), @@ -194,6 +189,7 @@ class _PlayerMoreSheetState extends State { Widget _optionRow({ required Key rowKey, required Key optionsKey, + required Key trackKey, required IconData icon, required String title, required List options, @@ -201,45 +197,52 @@ class _PlayerMoreSheetState extends State { return SizedBox( key: rowKey, height: _rowHeight, - child: Row( - children: [ - SizedBox( - width: 88, - child: Row( - children: [ - Icon(icon, color: AppColors.muted, size: 18), - const SizedBox(width: 6), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 12, - fontWeight: FontWeight.w600, + 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.w600, + ), + ), + ), + const SizedBox(width: _rowChevronGap), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: SingleChildScrollView( + key: optionsKey, + scrollDirection: Axis.horizontal, + child: Container( + key: trackKey, + height: _segmentTrackHeight, + padding: const EdgeInsets.symmetric( + horizontal: _segmentTrackPadding, + ), + decoration: BoxDecoration( + color: AppColors.raised, + borderRadius: BorderRadius.circular(_segmentTrackRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: options, ), ), ), - ], - ), - ), - const SizedBox(width: 6), - Expanded( - child: SingleChildScrollView( - key: optionsKey, - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < options.length; index++) ...[ - if (index > 0) const SizedBox(width: _segmentGap), - options[index], - ], - ], ), ), - ), - ], + ], + ), ), ); } @@ -275,26 +278,34 @@ class _PlayerMoreSheetState extends State { onTap: onTap, child: SizedBox( height: _rowHeight, - child: Row( - children: [ - Icon(icon, color: AppColors.muted, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - title, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w600, + 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.w600, + ), ), ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: 18, - ), - ], + const SizedBox(width: _rowChevronGap), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: _rowIconSize, + ), + ], + ), ), ), ), @@ -330,36 +341,41 @@ class _SegmentButton extends StatelessWidget { @override Widget build(BuildContext context) { - final background = selected - ? AppColors.blue - : enabled - ? AppColors.raised - : AppColors.cardAlt; final foreground = selected - ? AppColors.onPrimary - : enabled ? AppColors.onSurface + : enabled + ? AppColors.muted : AppColors.subtle; return Material( - color: background, + color: Colors.transparent, borderRadius: BorderRadius.circular(_segmentRadius), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(_segmentRadius), - child: SizedBox( + child: Container( height: _segmentHeight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: _segmentPadding), - child: Center( - child: Text( - label, - maxLines: 1, - style: TextStyle( - color: foreground, - fontSize: 12, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - ), - ), + padding: const EdgeInsets.symmetric(horizontal: _segmentPadding), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? AppColors.card : Colors.transparent, + borderRadius: BorderRadius.circular(_segmentRadius), + boxShadow: selected + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 4, + offset: Offset(0, 1), + ), + ] + : null, + ), + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: foreground, + fontSize: 14, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, ), ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index d8a7b6b..64c1ac7 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -50,7 +50,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more-close'))); + await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); @@ -290,7 +290,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more-close'))); + await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); // 长按弹更多不改播放状态,仍处于播放中。 expect(find.byKey(const Key('player-play-toggle')), findsNothing); @@ -832,6 +832,7 @@ void main() { expect(find.byKey(const Key('player-more-offline')), findsOneWidget); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-drag-handle')), findsOneWidget); + expect(find.byKey(const Key('player-more-close')), findsNothing); for (final removed in const [ Key('player-more-not-interested'), Key('player-more-danmaku'), @@ -845,15 +846,27 @@ void main() { } expect( tester.getSize(find.byKey(const Key('player-more-speed-1.0'))).height, - 28, + 26, + ); + expect( + tester.getSize(find.byKey(const Key('player-more-speed-track'))).height, + 34, ); expect( tester.getSize(find.byKey(const Key('player-more-speed-row'))).height, - 44, + 56, ); expect( tester.getSize(find.byKey(const Key('player-more-quality-row'))).height, - 44, + 56, + ); + expect( + tester.getSize(find.byKey(const Key('player-more-clear'))).height, + 56, + ); + expect( + tester.getSize(find.byKey(const Key('player-more-offline'))).height, + 56, ); expect( tester @@ -881,7 +894,7 @@ void main() { ) .style ?.fontSize, - 12, + 14, ); await tester.ensureVisible(find.byKey(const Key('player-more-speed-1.5'))); await tester.pumpAndSettle(); diff --git a/test/player/player_more_sheet_test.dart b/test/player/player_more_sheet_test.dart index 3929b53..5159498 100644 --- a/test/player/player_more_sheet_test.dart +++ b/test/player/player_more_sheet_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/player_more_sheet.dart'; @@ -14,6 +15,105 @@ void main() { expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); expect(find.byKey(const Key('player-more-pip')), findsNothing); + expect(find.byKey(const Key('player-more-close')), findsNothing); + }); + + testWidgets('should_render_reference_layout_metrics', (tester) async { + await _openSheet(tester); + + expect( + tester.widget(find.byKey(const Key('player-more-sheet'))).color, + AppColors.ink, + ); + expect( + tester.getSize(find.byKey(const Key('player-more-drag-handle'))), + const Size(36, 4), + ); + for (final key in const [ + Key('player-more-speed-row'), + Key('player-more-quality-row'), + Key('player-more-clear'), + Key('player-more-offline'), + ]) { + expect(tester.getSize(find.byKey(key)).height, 56); + } + for (final key in const [ + Key('player-more-speed-track'), + Key('player-more-quality-track'), + ]) { + expect(tester.getSize(find.byKey(key)).height, 34); + final trackDecoration = + tester.widget(find.byKey(key)).decoration! + as BoxDecoration; + expect(trackDecoration.color, AppColors.raised); + } + for (final key in const [ + Key('player-more-speed-0.75'), + Key('player-more-speed-1.0'), + Key('player-more-speed-1.25'), + ]) { + expect(tester.getSize(find.byKey(key)).height, 26); + } + + final speedLabel = tester.widget( + find + .descendant( + of: find.byKey(const Key('player-more-speed-row')), + matching: find.byType(Text), + ) + .first, + ); + expect(speedLabel.style?.fontSize, 16); + expect(speedLabel.style?.fontWeight, FontWeight.w600); + expect(tester.widget(find.byIcon(Icons.speed_outlined)).size, 22); + expect(tester.widget(find.byIcon(Icons.fullscreen)).size, 22); + + final selected = tester.widget( + find + .descendant( + of: find.byKey(const Key('player-more-speed-1.0')), + matching: find.byType(Container), + ) + .first, + ); + final selectedDecoration = selected.decoration! as BoxDecoration; + expect(selectedDecoration.color, AppColors.card); + expect(selectedDecoration.boxShadow, isNotEmpty); + final selectedText = tester.widget( + find.descendant( + of: find.byKey(const Key('player-more-speed-1.0')), + matching: find.text('1.0x'), + ), + ); + expect(selectedText.style?.color, AppColors.onSurface); + expect(selectedText.style?.fontWeight, FontWeight.w700); + + final unselected = tester.widget( + find + .descendant( + of: find.byKey(const Key('player-more-speed-1.25')), + matching: find.byType(Container), + ) + .first, + ); + final unselectedDecoration = unselected.decoration! as BoxDecoration; + expect(unselectedDecoration.color, Colors.transparent); + final unselectedText = tester.widget( + find.descendant( + of: find.byKey(const Key('player-more-speed-1.25')), + matching: find.text('1.25x'), + ), + ); + expect(unselectedText.style?.color, AppColors.muted); + expect(unselectedText.style?.fontWeight, FontWeight.w500); + + final disabledText = tester.widget( + find.descendant( + of: find.byKey(const Key('player-more-quality-720P')), + matching: find.byType(Text), + ), + ); + expect(disabledText.style?.color, AppColors.subtle); }); testWidgets('should_trigger_pip_and_close_sheet_when_tapped', (tester) async { From 622dc2f9e85898a1bcae067c820b39504ccebce7 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 10:43:36 +0800 Subject: [PATCH 08/49] =?UTF-8?q?fix(player):=20=E6=9B=B4=E5=A4=9A?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=96=87=E5=AD=97=E5=87=8F=E9=87=8D=E5=B9=B6?= =?UTF-8?q?=E7=BC=A9=E5=B0=8F=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/player_more_sheet.dart | 10 +++++----- test/player/player_fullscreen_test.dart | 6 +++--- test/player/player_more_sheet_test.dart | 10 ++++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 4f93858..18e9ad1 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -13,7 +13,7 @@ const double _groupRadius = 16; const double _rowHorizontalPadding = 14; const double _rowIconSize = 22; const double _rowIconGap = 14; -const double _rowLabelSize = 16; +const double _rowLabelSize = 15; const double _rowChevronGap = 10; const double _segmentTrackHeight = 34; const double _segmentHeight = 26; @@ -211,7 +211,7 @@ class _PlayerMoreSheetState extends State { style: const TextStyle( color: AppColors.onSurface, fontSize: _rowLabelSize, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, ), ), ), @@ -294,7 +294,7 @@ class _PlayerMoreSheetState extends State { style: const TextStyle( color: AppColors.onSurface, fontSize: _rowLabelSize, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, ), ), ), @@ -374,8 +374,8 @@ class _SegmentButton extends StatelessWidget { maxLines: 1, style: TextStyle( color: foreground, - fontSize: 14, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 64c1ac7..518738e 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -894,7 +894,7 @@ void main() { ) .style ?.fontSize, - 14, + 13, ); await tester.ensureVisible(find.byKey(const Key('player-more-speed-1.5'))); await tester.pumpAndSettle(); @@ -910,7 +910,7 @@ void main() { ) .style ?.fontWeight, - FontWeight.w700, + FontWeight.w600, ); expect( tester @@ -922,7 +922,7 @@ void main() { ) .style ?.fontWeight, - FontWeight.w500, + FontWeight.w400, ); }); diff --git a/test/player/player_more_sheet_test.dart b/test/player/player_more_sheet_test.dart index 5159498..8bfd01a 100644 --- a/test/player/player_more_sheet_test.dart +++ b/test/player/player_more_sheet_test.dart @@ -63,8 +63,8 @@ void main() { ) .first, ); - expect(speedLabel.style?.fontSize, 16); - expect(speedLabel.style?.fontWeight, FontWeight.w600); + expect(speedLabel.style?.fontSize, 15); + expect(speedLabel.style?.fontWeight, FontWeight.w500); expect(tester.widget(find.byIcon(Icons.speed_outlined)).size, 22); expect(tester.widget(find.byIcon(Icons.fullscreen)).size, 22); @@ -86,7 +86,8 @@ void main() { ), ); expect(selectedText.style?.color, AppColors.onSurface); - expect(selectedText.style?.fontWeight, FontWeight.w700); + expect(selectedText.style?.fontSize, 13); + expect(selectedText.style?.fontWeight, FontWeight.w600); final unselected = tester.widget( find @@ -105,7 +106,8 @@ void main() { ), ); expect(unselectedText.style?.color, AppColors.muted); - expect(unselectedText.style?.fontWeight, FontWeight.w500); + expect(unselectedText.style?.fontSize, 13); + expect(unselectedText.style?.fontWeight, FontWeight.w400); final disabledText = tester.widget( find.descendant( From 4b1aea29c724dfc2dd7978e439ea3a4d7c244283 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 10:52:14 +0800 Subject: [PATCH 09/49] =?UTF-8?q?feat(player):=20=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=E4=BA=92=E5=8A=A8=E6=A0=8F=E5=9B=BE=E6=A0=87=E6=8C=89=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=9B=BE=E6=9B=BF=E6=8D=A2=E5=B9=B6=E6=94=BE=E5=A4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/home_feed_overlay.dart | 9 +-- .../player/widgets/interaction_rail.dart | 27 +++---- lib/pages/player/widgets/player_overlay.dart | 11 +-- test/home/home_preview_layout_test.dart | 33 +++++++++ test/player/player_fullscreen_test.dart | 72 ++++++++++++++++++- 5 files changed, 128 insertions(+), 24 deletions(-) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index fb71a6b..41a1a84 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -173,7 +173,7 @@ class _HomeFeedOverlayState extends State { items: [ InteractionRailItem( key: const Key('rail-save'), - icon: Icons.star_border_rounded, + icon: Icons.star_rounded, activeIcon: Icons.star_rounded, label: widget.l10n.favorite, count: widget.favoriteCount, @@ -192,7 +192,7 @@ class _HomeFeedOverlayState extends State { ), InteractionRailItem( key: const Key('rail-like'), - icon: Icons.favorite_border_rounded, + icon: Icons.favorite_rounded, activeIcon: Icons.favorite_rounded, label: widget.l10n.like, count: widget.likeCount, @@ -202,9 +202,10 @@ class _HomeFeedOverlayState extends State { ), InteractionRailItem( key: const Key('rail-share'), - icon: Icons.ios_share_rounded, - activeIcon: Icons.ios_share_rounded, + icon: Icons.reply_rounded, + activeIcon: Icons.reply_rounded, label: widget.l10n.share, + mirrorIcon: true, onTap: widget.onShare, ), ], diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 1411bc3..dcd19e5 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -5,10 +5,9 @@ import 'package:flutter/material.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; -const double _buttonSize = 34; -const double _iconSize = 24; -const double _itemGap = 18; -const Color _buttonBackground = Color(0x80000000); +const double _buttonSize = 44; +const double _iconSize = 30; +const double _itemGap = 22; /// 互动栏单项:图标、计数与点击。 class InteractionRailItem { @@ -21,6 +20,7 @@ class InteractionRailItem { this.count, this.countKey, this.selected = false, + this.mirrorIcon = false, }); final Key key; @@ -31,6 +31,7 @@ class InteractionRailItem { final int? count; final Key? countKey; final bool selected; + final bool mirrorIcon; } /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 @@ -60,6 +61,10 @@ class _RailButton extends StatelessWidget { @override Widget build(BuildContext context) { + final icon = Icon( + item.selected ? item.activeIcon : item.icon, + size: _iconSize, + ); return Column( key: item.key, mainAxisSize: MainAxisSize.min, @@ -69,14 +74,10 @@ class _RailButton extends StatelessWidget { child: IconButton( onPressed: item.onTap, padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: _buttonBackground, - foregroundColor: item.selected ? AppColors.blue : Colors.white, - ), - icon: Icon( - item.selected ? item.activeIcon : item.icon, - size: _iconSize, - ), + color: item.selected ? AppColors.blue : Colors.white, + icon: item.mirrorIcon + ? Transform.flip(flipX: true, child: icon) + : icon, ), ), const SizedBox(height: 2), @@ -88,7 +89,7 @@ class _RailButton extends StatelessWidget { style: const TextStyle( color: Colors.white, height: 1, - fontSize: 11, + fontSize: 12, fontWeight: FontWeight.w600, shadows: [Shadow(blurRadius: 6, color: Colors.black)], ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 910cc13..f89e7ea 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -141,13 +141,13 @@ class PlayerPlaybackOverlay extends StatelessWidget { final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( right: AppDimens.spaceMd, - top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55), + top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43), child: InteractionRail( key: const Key('player-interaction-rail'), items: [ InteractionRailItem( key: const Key('player-rail-save'), - icon: Icons.star_border_rounded, + icon: Icons.star_rounded, activeIcon: Icons.star_rounded, label: data.l10n.favorite, count: data.favoriteCount, @@ -166,7 +166,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), InteractionRailItem( key: const Key('player-rail-like'), - icon: Icons.favorite_border_rounded, + icon: Icons.favorite_rounded, activeIcon: Icons.favorite_rounded, label: data.l10n.like, count: data.likeCount, @@ -176,9 +176,10 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), InteractionRailItem( key: const Key('player-rail-share'), - icon: Icons.ios_share_rounded, - activeIcon: Icons.ios_share_rounded, + icon: Icons.reply_rounded, + activeIcon: Icons.reply_rounded, label: data.l10n.share, + mirrorIcon: true, onTap: actions.onShare, ), ], diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 5059184..f01ab09 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -150,6 +150,39 @@ void main() { tester.widget(find.byKey(const Key('rail-comments-count'))).data, '12', ); + expect( + find.descendant( + of: find.byKey(const Key('rail-save')), + matching: find.byIcon(Icons.star_rounded), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('rail-like')), + matching: find.byIcon(Icons.favorite_rounded), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('rail-share')), + matching: find.byIcon(Icons.reply_rounded), + ), + findsOneWidget, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('rail-share')), + matching: find.byType(Transform), + ), + ) + .transform + .storage[0], + -1, + ); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 518738e..14b6b1b 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -466,7 +466,7 @@ void main() { expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); - expect(rail.top, greaterThanOrEqualTo(screenHeight * 0.55 - 2)); + expect(rail.top, greaterThanOrEqualTo(screenHeight * 0.40)); expect(rail.bottom, lessThanOrEqualTo(bottom.top)); expect(find.byKey(const Key('player-author-note')), findsNothing); expect( @@ -478,7 +478,7 @@ void main() { ), ) .width, - 24, + 30, ); await tester.tap(find.byKey(const Key('player-synopsis-toggle'))); @@ -580,6 +580,74 @@ void main() { expect(railLabel(const Key('player-rail-save')), '3201'); }); + testWidgets('should_render_reference_rail_icons_without_background', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + Finder railIcon(Key key, IconData icon) => + find.descendant(of: find.byKey(key), matching: find.byIcon(icon)); + expect( + railIcon(const Key('player-rail-save'), Icons.star_rounded), + findsOneWidget, + ); + expect( + railIcon(const Key('player-rail-like'), Icons.favorite_rounded), + findsOneWidget, + ); + expect( + railIcon( + const Key('player-rail-comments'), + Icons.chat_bubble_outline_rounded, + ), + findsOneWidget, + ); + final shareIcon = railIcon( + const Key('player-rail-share'), + Icons.reply_rounded, + ); + expect(shareIcon, findsOneWidget); + expect(tester.widget(shareIcon).size, 30); + + final mirror = tester.widget( + find.descendant( + of: find.byKey(const Key('player-rail-share')), + matching: find.byType(Transform), + ), + ); + expect(mirror.transform.storage[0], -1); + + final saveButton = tester.widget( + find.descendant( + of: find.byKey(const Key('player-rail-save')), + matching: find.byType(IconButton), + ), + ); + expect(saveButton.style?.backgroundColor, isNull); + expect( + tester.getSize( + find.descendant( + of: find.byKey(const Key('player-rail-save')), + matching: find.byType(IconButton), + ), + ), + const Size(44, 44), + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('player-rail-like')), + matching: find.byType(Text), + ), + ) + .style + ?.fontSize, + 12, + ); + }); + testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); From cbb9f60f7fef2b33db4414ef5155fe9d417e668d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 11:02:05 +0800 Subject: [PATCH 10/49] =?UTF-8?q?feat(player):=20=E4=BA=92=E5=8A=A8?= =?UTF-8?q?=E6=A0=8F=E6=94=B9=E7=94=A8=E5=8F=82=E8=80=83=E5=9B=BE=E5=90=8C?= =?UTF-8?q?=E6=AC=BE=E5=9B=BE=E6=A0=87=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/rail/rail_comment.png | Bin 0 -> 1316 bytes assets/icon/rail/rail_like.png | Bin 0 -> 928 bytes assets/icon/rail/rail_save.png | Bin 0 -> 1093 bytes assets/icon/rail/rail_share.png | Bin 0 -> 1004 bytes .../player/widgets/home_feed_overlay.dart | 13 ++--- .../player/widgets/interaction_rail.dart | 28 +++++------ lib/pages/player/widgets/player_overlay.dart | 13 ++--- pubspec.yaml | 1 + test/home/home_preview_layout_test.dart | 39 +++++++-------- test/player/player_fullscreen_test.dart | 45 +++++++++--------- 10 files changed, 61 insertions(+), 78 deletions(-) create mode 100644 assets/icon/rail/rail_comment.png create mode 100644 assets/icon/rail/rail_like.png create mode 100644 assets/icon/rail/rail_save.png create mode 100644 assets/icon/rail/rail_share.png diff --git a/assets/icon/rail/rail_comment.png b/assets/icon/rail/rail_comment.png new file mode 100644 index 0000000000000000000000000000000000000000..cc358c12db8aacc32579bf0f144ea12fe651397a GIT binary patch literal 1316 zcmV+<1>5?GP) zF>uo`6o!BI7!tOywQT9uu(cbom94PRv23ux2wPxlx3*imHEiuzHnIU*yAif#BW(3X zyNT2Be5bgvEy12~3bM2>WtYXH8J(4v4`Y!mRdKLHq{#VKV7fYED` z5*r4^<~#xLmCkhm;3a?#S`guD0M02zxWYDov!+G(TOpGBaEnvAw)ahla4z%?fOYsm z4B$C{A3h4sbLJ-e?YScLOjeSl_j z31Gv82tN|U<|SX#n9-<~cl@mG2>+WJUbls)jtFNh6`S)@w{F*s@aqLb@wPG1sbF*j zcVf27*YvWa5!r#e{g@TOhe8_Ry>{R~U8>ZKrW7u8i0*v&9LyI}f``1FO)T8`k%^ST zOQ1kDVzsu5l7gQ)IDhRjyf)t|lTv;gUg~NCd;xn^C&Kxy62w-gPZgbVgolo@91A

r0HJA7ML>bHP(Dlk6{OADZX+meba*Vp~5MW1Wn) zHW_1A%pA*N`->u+Rk#wu3{HK@P9e+M9heQXsI4kxPx82~#4)kVJhPCxijeh|JcETi zo)+BH!te*l<8EoPN2#YTA*Rd+k}!5%aK4JI!>Hi@j}2aj%7f)OUyuS+uW6A*I=OS}SWMw*GgI)Qd}@Qug8XN)Hm+lS zf0`%w+^123YXysQ5h|wsD~V$Y*Byy{SV$>0>u{OPN=&jXncA7RnMg)C+!(-kC%AwQa>-+OgmlB zan2}}_AD7xqawk5-V7y6dfT{7<^*4|IHYx%Zbj$0Q(=WAHb<7&nz&cT39U<7i=2vo<+T2mP5nmL0%MUTvpZ1AO+XIyp%L|Zq%>XJK8BNO4DwNKqX4vG#4 zdwk4dWRH(jLjg!>q|#|-Zlef5))t~9_*A9g7BN+@g(#PWyx}1F{A$u_6;G2010v17 zO?Tka_D=LhcWhE-q#0HDL`w${SKnXgIvh+qWUOruKG;z7C>ozjiCdIncFKrt`uX9J zr4@fyh+KU#XA@IQWU3(>)3b5II@HKyV>ai*x5=neD(BUV@xICS0>mNp>XD@#Vzyka zk*z|Yj_-izP>%09I-t34^8|G)vE9;}ipc+>yH&+T$!Aq;m%imhe1y@khUlNO6t_w6 zEMt7yC2NvO2W>tG&S4fT)3uC_eXvP%47HPpeOc#JgbxwU47OGKoR1F?o)DMVaW`;5 zY##)_5dNOt4$_x!Kpl|K{7dni!1a{EG=677>#j~3n3^K^4b|>5TGTbQ-?Vip1(N&s zlgi_v%VPT=_=x_@8hOr0=%?@m*9Cu-5#4)2M0~7Z)vk z(6@W%{m=s+^X)^~pBZC6&dRd9>e-J=PE8bJbnzR&A>CgB7+bV;3*e~dYo7zS0S~7D zzSuUxA+pOD@DG3k!)w0*ICj{VCjbiHK6wgY0k=G*FY)n)Yj)|H0o5&Y5 za82NcC4hG=|9=Z$UVH4iKl+RO?!#U9Oc4tbc#j@`1n`OO|E>9b+cBUI%9Y6V4;GSn zP56)~Zjoh@V-@`i+5wYK7J=ItQ|M|fvd^q6H44fN&TQPpZe#Qo)}zOWhd!44qvLtO zK&w$lvY~H+8Bw{jOU*fE85>nzzyXu;!}jJ z`Pn71gl+arxD4Y#>BwYH9*c*Hw9TWqj3(MBvR#$yM|&oz<1)ue&Z1pls`t#W!0NgO9jN z_lKTBw;`4G{V8ZwT5(Qw>c*s2`9DSir?C4eW|YfGd%YEQ=v@2Q_Nz;|E-~DS`ZvDz zR_o&xW&_L)mG2Gg_p~9U8fkM!g;+@4r`fs*S#Yl0+2jfwtJTlw?>OUC=LefcI1d$B zG+ah5zFAo}zVL8J%6)Y;=~%yea>j}@fqI#f!va4AlYsuT+j zRYE>mrCimyUpqG_*5W{?zdG&^ZWA+_?}&*L9|e{^MXo4o7W4w)IH1G*kfX?mAo5T{ zsI*ssCX|*TqMnf38Fm^U*!55M+~jz%oZLkd*PRF&v&XVAM>(?OB8H?W7LlvbzS__- zXFQHKII*~kCLEm#Zox5HGM7LqCV^uX86;Fa9XAR2q7pX+k*o8P4-J;GKF2C>%s#n| zwvNtISR{zulIy6();E=cz^PesvZQ3m$s!`dJ^uh3l=jCA%ej^S0000 zEl?Xl6vzK}QxloGrY<;;;EDuSB)Ci+bx>K0Yh8mYj!KZ=T1j2&TFE)6Yg|*eoo)AK zmNfU3+mH8l_cz0kA<16&-@CVO-+Oz>gb+yhA@_9LL=xXG$?`qIS^SRxg!;JyLo%il z?gfDTC4bMr2#%J2SRuTGl_hbsd;#Ddz^LMRHrCJ0@_)5>4I69XCjLPEuCn@)XJ7y) z@!xcK?F|^f$>)m|-b32|bFqAZ0LcRvUVtzh#19tg;tL{UL?`~;3hyau@<8OE9PJ3h zjS zx5Zp5#(0O4<$nRN>F{{ot)GhJPaF#`@osfg#Q)&wxbG3U_~)L(+Gu_L4uPeAp;o_9 zS^fn9;U#0O|HsD4+Pmf7s(-Syv@z(1q`y^{gj{7G*jSj^5MNr)6k$>T_@n}a&%VW% zKKmVj4aCbmfHMGJ0bJV@e_x5+)VdKng*qW;%58NPf1+CRwyk}{xVThvN2B=OSAT&q zR}EhW>LgT(FUxnyt=JL&K~8B|oIg^NNWvVY@X(7xd5ae(%rdJJf6qecF7c09n+aE6 zk?Q^`VX%gfw`j9S!kb~O>mn;fJ{GHHOUVh+q$sGpPygOju56oLNQ*8Db>h6k zAf#ID7%(-RSkt4R#Q&6$p-efyfQ6K@{)W0OnX$6nHrJo`^kYf*hg^0kvh*pYq)GT8 zvck0sM+Nl7nfyL7p>?*p3U8{0E0`3;O|Wc^+g)-poEFeUq^fkIA89|Jh;JBUf~^%D>>dtF;ZT(%2_-0g51GEQB}-nZQt!D=_geiCW0RZsF(lf^PPjh< zN-@k-5{-@Jha$W+(jqI+MOJ7`xQH(|%7w-u7xC8-9%C-z$5hcGCw_>L&X9}vPY@mh zE}>>9+02z&0b>a@3$Csw4=@UuTRfK0-qaApcNuA|Qy0JLYm{tp#Wl5Tx^Ks(`1563 z=xZi=8?B2Q)GgoEH}wpZ=yeIM^7*MI{7Ox%E+$+Xp{5{|Q9QD6*Q+ds3HP{#r8jZf zbBw}ff{lv!GwpFHR_{jC1NN3bi5rs&RJasL^Um9p`mT{62P(E;sG- zH`NWP+wShj!0#s7#_GAIEq~c1y?8~899ntTtD$OgF1PZ-!J8cwvYs?t+3ap)9W_)!%_vFY!ykVDMREkw{^+$?00000 LNkvXXu0mjf_MsZ+ literal 0 HcmV?d00001 diff --git a/assets/icon/rail/rail_share.png b/assets/icon/rail/rail_share.png new file mode 100644 index 0000000000000000000000000000000000000000..59c79214fc6b2067e815b4c72815ec9cad3e54fc GIT binary patch literal 1004 zcmV zF>@0!5QX1)Twpq7Wc9&_T!0;f@wMpiGD9a7XBH7t-PK(kUZG zH=`(bw(g|U?MnJ)GRZiObN8}-TCG+}A%s9C9HW50E0YBe54Q-v1?G&w15xu>0ftbI z@OuqnLp}Zh%qWOwDZ=78&jGv#6DSM*2*6j%=av9&g8`HU7naZa3t*p%uT%GM8!EA@ zKhhBh6NBJ7YXD0CFF_Q_mhun4)Zyn>0A?TtWx<7~*CFNWu~|x8_ChM6Vi$a(K1 zfB>-zZl;f4Ffb{2IH^feN)cXaHuGz!i%2oT%}LV*0+LdMH~IQA1SItc*Jr4A5r8as z;7L;ImJS&G zxX+#C09V1~U-dyE;(D1RxmrpVyB<;Vu6K^`bEAAiHp; ztU9#sfXW4-ZPEtsn!ZMjX;QfRexkr=amNx zWIVM{j4Tcg$iCgo=eGSSyVAncvy-*4Y^bNjt=2Ltvf#Vf)O6E!?!ww;DYXUk1eqEU z?$q+OEy7c`6O)GkPT@#S3j=RO8Esjs%)s43Yz5E$jsN!HeFsF7;Ih({8l_QIm7eXL zra?310rN3{Z!zy;JJa&rh1#I%bze;jiax~-m8ERd5vCJ$9+fO#{stXZD%TFO6M5Xw z-|dtAE>qxu)UM2gv=mveav5;u>ISNhOM=dg#irCx>F<>2>Cs?F}lnjo?#l z5ai5p4`YlsOL?KyQn(9E?jD9dO=24&HnFKp?R~F7k`+5f!4K3_out??2);Bjs#A(b zSjXz&a{xa;9Lj5&g{A0h{^14(OIcZbpN-fY1fOlG*DR>}H8&4$YAz-3(=iFo9_p5Y a*th~1T { items: [ InteractionRailItem( key: const Key('rail-save'), - icon: Icons.star_rounded, - activeIcon: Icons.star_rounded, + asset: 'assets/icon/rail/rail_save.png', label: widget.l10n.favorite, count: widget.favoriteCount, countKey: const Key('rail-save-count'), @@ -183,8 +182,7 @@ class _HomeFeedOverlayState extends State { ), InteractionRailItem( key: const Key('rail-comments'), - icon: Icons.chat_bubble_outline_rounded, - activeIcon: Icons.chat_bubble_rounded, + asset: 'assets/icon/rail/rail_comment.png', label: widget.l10n.comments, count: widget.commentCount, countKey: const Key('rail-comments-count'), @@ -192,8 +190,7 @@ class _HomeFeedOverlayState extends State { ), InteractionRailItem( key: const Key('rail-like'), - icon: Icons.favorite_rounded, - activeIcon: Icons.favorite_rounded, + asset: 'assets/icon/rail/rail_like.png', label: widget.l10n.like, count: widget.likeCount, countKey: const Key('rail-like-count'), @@ -202,10 +199,8 @@ class _HomeFeedOverlayState extends State { ), InteractionRailItem( key: const Key('rail-share'), - icon: Icons.reply_rounded, - activeIcon: Icons.reply_rounded, + asset: 'assets/icon/rail/rail_share.png', label: widget.l10n.share, - mirrorIcon: true, onTap: widget.onShare, ), ], diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index dcd19e5..f3ff832 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -6,32 +6,28 @@ import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; const double _buttonSize = 44; -const double _iconSize = 30; -const double _itemGap = 22; +const double _iconSize = 32; +const double _itemGap = 24; /// 互动栏单项:图标、计数与点击。 class InteractionRailItem { const InteractionRailItem({ required this.key, - required this.icon, - required this.activeIcon, + required this.asset, required this.label, required this.onTap, this.count, this.countKey, this.selected = false, - this.mirrorIcon = false, }); final Key key; - final IconData icon; - final IconData activeIcon; + final String asset; final String label; final VoidCallback onTap; final int? count; final Key? countKey; final bool selected; - final bool mirrorIcon; } /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 @@ -61,10 +57,6 @@ class _RailButton extends StatelessWidget { @override Widget build(BuildContext context) { - final icon = Icon( - item.selected ? item.activeIcon : item.icon, - size: _iconSize, - ); return Column( key: item.key, mainAxisSize: MainAxisSize.min, @@ -74,10 +66,14 @@ class _RailButton extends StatelessWidget { child: IconButton( onPressed: item.onTap, padding: EdgeInsets.zero, - color: item.selected ? AppColors.blue : Colors.white, - icon: item.mirrorIcon - ? Transform.flip(flipX: true, child: icon) - : icon, + icon: Image.asset( + item.asset, + width: _iconSize, + height: _iconSize, + fit: BoxFit.contain, + color: item.selected ? AppColors.blue : Colors.white, + colorBlendMode: BlendMode.srcIn, + ), ), ), const SizedBox(height: 2), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index f89e7ea..9896595 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -147,8 +147,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { items: [ InteractionRailItem( key: const Key('player-rail-save'), - icon: Icons.star_rounded, - activeIcon: Icons.star_rounded, + asset: 'assets/icon/rail/rail_save.png', label: data.l10n.favorite, count: data.favoriteCount, countKey: const Key('player-rail-save-count'), @@ -157,8 +156,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), InteractionRailItem( key: const Key('player-rail-comments'), - icon: Icons.chat_bubble_outline_rounded, - activeIcon: Icons.chat_bubble_rounded, + asset: 'assets/icon/rail/rail_comment.png', label: data.l10n.comments, count: data.commentCount, countKey: const Key('player-rail-comments-count'), @@ -166,8 +164,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), InteractionRailItem( key: const Key('player-rail-like'), - icon: Icons.favorite_rounded, - activeIcon: Icons.favorite_rounded, + asset: 'assets/icon/rail/rail_like.png', label: data.l10n.like, count: data.likeCount, countKey: const Key('player-rail-like-count'), @@ -176,10 +173,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { ), InteractionRailItem( key: const Key('player-rail-share'), - icon: Icons.reply_rounded, - activeIcon: Icons.reply_rounded, + asset: 'assets/icon/rail/rail_share.png', label: data.l10n.share, - mirrorIcon: true, onTap: actions.onShare, ), ], diff --git a/pubspec.yaml b/pubspec.yaml index 09d3088..5951cc5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,7 @@ flutter: assets: - assets/icon/splash_logo.png - assets/icon/splash_logo_android12.png + - assets/icon/rail/ # To add assets to your application, add an assets section, like this: # assets: diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index f01ab09..876b140 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -150,38 +150,33 @@ void main() { tester.widget(find.byKey(const Key('rail-comments-count'))).data, '12', ); + String railAsset(Key key) { + final image = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(Image)), + ); + return (image.image as AssetImage).assetName; + } + + expect(railAsset(const Key('rail-save')), 'assets/icon/rail/rail_save.png'); expect( - find.descendant( - of: find.byKey(const Key('rail-save')), - matching: find.byIcon(Icons.star_rounded), - ), - findsOneWidget, + railAsset(const Key('rail-comments')), + 'assets/icon/rail/rail_comment.png', ); + expect(railAsset(const Key('rail-like')), 'assets/icon/rail/rail_like.png'); expect( - find.descendant( - of: find.byKey(const Key('rail-like')), - matching: find.byIcon(Icons.favorite_rounded), - ), - findsOneWidget, - ); - expect( - find.descendant( - of: find.byKey(const Key('rail-share')), - matching: find.byIcon(Icons.reply_rounded), - ), - findsOneWidget, + railAsset(const Key('rail-share')), + 'assets/icon/rail/rail_share.png', ); expect( tester - .widget( + .widget( find.descendant( of: find.byKey(const Key('rail-share')), - matching: find.byType(Transform), + matching: find.byType(Image), ), ) - .transform - .storage[0], - -1, + .colorBlendMode, + BlendMode.srcIn, ); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 14b6b1b..d47ee87 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -474,11 +474,11 @@ void main() { .getSize( find.descendant( of: find.byKey(const Key('player-rail-comments')), - matching: find.byIcon(Icons.chat_bubble_outline_rounded), + matching: find.byType(Image), ), ) .width, - 30, + 32, ); await tester.tap(find.byKey(const Key('player-synopsis-toggle'))); @@ -586,37 +586,31 @@ void main() { await _pumpApp(tester); await _openSeriesPlayer(tester); - Finder railIcon(Key key, IconData icon) => - find.descendant(of: find.byKey(key), matching: find.byIcon(icon)); expect( - railIcon(const Key('player-rail-save'), Icons.star_rounded), - findsOneWidget, + _railAssetName(tester, const Key('player-rail-save')), + 'assets/icon/rail/rail_save.png', ); expect( - railIcon(const Key('player-rail-like'), Icons.favorite_rounded), - findsOneWidget, + _railAssetName(tester, const Key('player-rail-comments')), + 'assets/icon/rail/rail_comment.png', ); expect( - railIcon( - const Key('player-rail-comments'), - Icons.chat_bubble_outline_rounded, - ), - findsOneWidget, + _railAssetName(tester, const Key('player-rail-like')), + 'assets/icon/rail/rail_like.png', ); - final shareIcon = railIcon( - const Key('player-rail-share'), - Icons.reply_rounded, + expect( + _railAssetName(tester, const Key('player-rail-share')), + 'assets/icon/rail/rail_share.png', ); - expect(shareIcon, findsOneWidget); - expect(tester.widget(shareIcon).size, 30); - - final mirror = tester.widget( + final shareImage = tester.widget( find.descendant( of: find.byKey(const Key('player-rail-share')), - matching: find.byType(Transform), + matching: find.byType(Image), ), ); - expect(mirror.transform.storage[0], -1); + expect(shareImage.width, 32); + expect(shareImage.height, 32); + expect(shareImage.colorBlendMode, BlendMode.srcIn); final saveButton = tester.widget( find.descendant( @@ -1132,6 +1126,13 @@ void main() { }); } +String _railAssetName(WidgetTester tester, Key itemKey) { + final image = tester.widget( + find.descendant(of: find.byKey(itemKey), matching: find.byType(Image)), + ); + return (image.image as AssetImage).assetName; +} + void _expectPlayerNavigationBar(WidgetTester tester) { final overlay = tester.widget>( find.byKey(const Key('player-system-overlay')).last, From 5e2b8d9e4db19c6f7967f7ed58528671fa272156 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 11:39:58 +0800 Subject: [PATCH 11/49] =?UTF-8?q?fix(player):=20=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E4=B8=8E=E8=AF=84=E8=AE=BA=E6=95=B0=E5=AE=9E=E6=97=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/remote/comment_api.dart | 10 +++ .../data/repositories/comment_repository.dart | 6 ++ .../drama_detail/drama_detail_logic.dart | 23 ++++- lib/pages/drama_detail/drama_detail_view.dart | 6 +- .../widgets/drama_detail_sections.dart | 13 ++- lib/pages/player/player_view.dart | 39 ++++----- test/comment/comment_api_test.dart | 27 ++++++ .../drama_detail/drama_detail_logic_test.dart | 59 ++++++++++++- test/drama_detail/drama_detail_view_test.dart | 22 +++++ test/player/player_fullscreen_test.dart | 86 ++++++++++++++++++- .../fake_interaction_repositories.dart | 13 +++ 11 files changed, 275 insertions(+), 29 deletions(-) diff --git a/lib/core/data/remote/comment_api.dart b/lib/core/data/remote/comment_api.dart index f99438e..536523d 100644 --- a/lib/core/data/remote/comment_api.dart +++ b/lib/core/data/remote/comment_api.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/comment.dart'; +import '../models/json_numbers.dart'; import 'api_client.dart'; /// 评论接口,对应契约 §1.7 的 `/app/comments*`。 @@ -29,6 +30,15 @@ class CommentApi { return CommentPage.fromJson(data); } + /// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。 + Future count(String dramaId) async { + final data = await _client.get( + '/app/comments/count', + query: {'dramaId': dramaId}, + ); + return jsonToInt(data); + } + /// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。 Future publish({ required String dramaId, diff --git a/lib/core/data/repositories/comment_repository.dart b/lib/core/data/repositories/comment_repository.dart index 92d93e2..744daf9 100644 --- a/lib/core/data/repositories/comment_repository.dart +++ b/lib/core/data/repositories/comment_repository.dart @@ -13,6 +13,9 @@ abstract interface class CommentRepository { int size = commentPageSize, }); + /// 查询单部剧的有效评论总数。 + Future count(String dramaId); + /// 发表评论或回复。 Future publish({ required String dramaId, @@ -44,6 +47,9 @@ class HttpCommentRepository implements CommentRepository { size: size, ); + @override + Future count(String dramaId) => _api.count(dramaId); + @override Future publish({ required String dramaId, diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 9dc74df..42c0a73 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -35,6 +35,7 @@ class DramaDetailLogic extends ChangeNotifier { int? continueIndex; WatchProgress? progress; String? progressError; + int _favoriteCountDelta = 0; bool _disposed = false; void notify() { @@ -118,8 +119,26 @@ class DramaDetailLogic extends ChangeNotifier { await loadContinue(); } - /// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。 - Future toggleFavorite() => favorites.toggle(drama.id, card: drama); + /// 详情页展示用的收藏数;没有服务端计数时继续保持缺省展示。 + int? get adjustedFavoriteCount { + final base = drama.favoriteCount; + if (base == null) return null; + final adjusted = base + _favoriteCountDelta; + return adjusted < 0 ? 0 : adjusted; + } + + /// 收藏切换:乐观状态由控制器负责回滚,这里只在成功后同步计数。 + Future toggleFavorite() async { + final wasSaved = favorites.isSaved(drama.id); + final ok = await favorites.toggle(drama.id, card: drama); + if (!ok) return false; + final nowSaved = favorites.isSaved(drama.id); + if (nowSaved != wasSaved) { + _favoriteCountDelta += nowSaved ? 1 : -1; + notify(); + } + return true; + } @override void dispose() { diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index d3f0725..b995301 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -142,7 +142,11 @@ class _DramaDetailPageState extends State { ), ), SliverToBoxAdapter( - child: DramaStatsCard(l10n: l10n, drama: drama), + child: DramaStatsCard( + l10n: l10n, + drama: drama, + favoriteCount: logic.adjustedFavoriteCount, + ), ), SliverToBoxAdapter( child: DramaSynopsisSection( diff --git a/lib/pages/drama_detail/widgets/drama_detail_sections.dart b/lib/pages/drama_detail/widgets/drama_detail_sections.dart index 1b8981f..8c5eda1 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_sections.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_sections.dart @@ -29,16 +29,23 @@ const TextStyle _synopsisStyle = TextStyle( /// 详情页三项数据卡:热度、收藏与点赞,缺失值不做伪造。 class DramaStatsCard extends StatelessWidget { - const DramaStatsCard({super.key, required this.l10n, required this.drama}); + const DramaStatsCard({ + super.key, + required this.l10n, + required this.drama, + this.favoriteCount, + }); final AppLocalizations l10n; final Drama drama; + final int? favoriteCount; @override Widget build(BuildContext context) { - final favorite = drama.favoriteCount == null + final currentFavoriteCount = favoriteCount ?? drama.favoriteCount; + final favorite = currentFavoriteCount == null ? l10n.episodeCountLabel(drama.episodeCount) - : '${formatInteractionCount(drama.favoriteCount!)} ${l10n.favorite}'; + : '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}'; final like = drama.likeCount == null ? '—' : '${formatInteractionCount(drama.likeCount!)} ${l10n.like}'; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 96c5af8..91d05cb 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; @@ -99,6 +100,7 @@ class _PlayerPageState extends State /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; + final Map _commentCounts = {}; @override void initState() { @@ -203,6 +205,18 @@ class _PlayerPageState extends State }); } + Future _openComments(BuildContext context, Drama drama) async { + final comments = context.read(); + await openCommentsWithAuth(context, drama, show: logic.showComments); + try { + final count = await comments.count(drama.id); + if (!mounted) return; + setState(() => _commentCounts[drama.id] = count); + } catch (_) { + // 评论面板已经关闭,计数刷新失败时保留旧值即可。 + } + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -446,7 +460,7 @@ class _PlayerPageState extends State favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - commentCount: drama.commentCount, + commentCount: _commentCounts[drama.id] ?? drama.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { @@ -465,21 +479,8 @@ class _PlayerPageState extends State } } }, - onSave: () async { - final favorites = context.read(); - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.favoriteToggleFailed)), - ); - } - }, - onComments: () => unawaited( - openCommentsWithAuth(context, drama, show: logic.showComments), - ), + onSave: () => unawaited(_toggleSave(context, drama)), + onComments: () => unawaited(_openComments(context, drama)), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), onOpenFullPlayer: () async { @@ -502,7 +503,7 @@ class _PlayerPageState extends State controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, - commentCount: drama.commentCount, + commentCount: _commentCounts[drama.id] ?? drama.commentCount, favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), @@ -523,9 +524,7 @@ class _PlayerPageState extends State onDetail: () => unawaited(logic.openDetail(context)), onSpeed: () => unawaited(_openSpeedSheet(context)), onEpisodes: () => logic.showEpisodes(context), - onComments: () => unawaited( - openCommentsWithAuth(context, drama, show: logic.showComments), - ), + onComments: () => unawaited(_openComments(context, drama)), onLike: () => unawaited(_toggleLike(context, interactions, drama)), onSave: () => unawaited(_toggleSave(context, drama)), diff --git a/test/comment/comment_api_test.dart b/test/comment/comment_api_test.dart index a83dfce..6c25dbb 100644 --- a/test/comment/comment_api_test.dart +++ b/test/comment/comment_api_test.dart @@ -126,4 +126,31 @@ void main() { '/app/comments/2111012753262120962', ); }); + + test('should_parse_numeric_comment_count', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(17)), + ); + final api = CommentApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final count = await api.count('5'); + + expect(count, 17); + expect(adapter.requests.single.method, 'GET'); + expect(adapter.requests.single.uri.path, '/app/comments/count'); + expect(adapter.requests.single.queryParameters, {'dramaId': '5'}); + }); + + test('should_parse_string_comment_count', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope('2111012753262120962')), + ); + final api = CommentApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + expect(await api.count('5'), 2111012753262120962); + }); } diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart index 48b5e86..97aa4f1 100644 --- a/test/drama_detail/drama_detail_logic_test.dart +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -9,12 +9,16 @@ import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart'; import '../support/fake_watch_history.dart'; /// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。 -DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) => +DramaDetailLogic _logic( + Drama drama, + FakeWatchHistory history, { + FavoriteController? favorites, +}) => DramaDetailLogic( drama: drama, history: history, playback: FakePlaybackRepository(), - favorites: FavoriteController(InMemoryFavoriteRepository()), + favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), ); Drama _card(String id) => Drama( @@ -98,4 +102,55 @@ void main() { expect(logic.drama.episodes, hasLength(3)); logic.dispose(); }); + + test('should_adjust_favorite_count_only_after_successful_changes', () async { + final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]); + final favorites = FavoriteController(repository); + await favorites.load(); + final logic = _logic( + _favoriteDrama, + FakeWatchHistory(), + favorites: favorites, + ); + + expect(logic.adjustedFavoriteCount, 2); + expect(await logic.toggleFavorite(), isTrue); + expect(logic.adjustedFavoriteCount, 3); + + repository.failToggle = true; + expect(await logic.toggleFavorite(), isFalse); + expect(logic.adjustedFavoriteCount, 3); + + repository.failToggle = false; + expect(await logic.toggleFavorite(), isTrue); + expect(logic.adjustedFavoriteCount, 2); + logic.dispose(); + }); + + test('should_decrement_favorite_count_from_saved_state', () async { + final repository = InMemoryFavoriteRepository( + saved: {'favorite-detail'}, + cards: [_favoriteDrama], + ); + final favorites = FavoriteController(repository); + await favorites.load(); + final logic = _logic( + _favoriteDrama, + FakeWatchHistory(), + favorites: favorites, + ); + + expect(await logic.toggleFavorite(), isTrue); + expect(logic.adjustedFavoriteCount, 1); + logic.dispose(); + }); } + +const _favoriteDrama = Drama( + id: 'favorite-detail', + title: 'Favorite detail', + synopsis: 'favorite count', + posterUrl: '/files/favorite-detail.jpg', + favoriteCount: 2, + detailLoaded: true, +); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index f66c0ba..490e7bd 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -140,11 +140,23 @@ void main() { expect(decoration().color, AppColors.card); expect((decoration().border! as Border).top.color, AppColors.blue); + expect( + tester + .widget(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '3200 Favorite', + ); await tester.tap(find.byKey(const Key('detail-favorite'))); await tester.pumpAndSettle(); expect(decoration().color, AppColors.blue); + expect( + tester + .widget(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '3201 Favorite', + ); expect( find.descendant( of: find.byKey(const Key('detail-favorite')), @@ -152,6 +164,16 @@ void main() { ), findsOneWidget, ); + + await tester.tap(find.byKey(const Key('detail-favorite'))); + await tester.pumpAndSettle(); + + expect( + tester + .widget(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '3200 Favorite', + ); }); testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', ( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index d47ee87..4c809b0 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -175,6 +175,68 @@ void main() { expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); }); + testWidgets('should_refresh_comment_count_after_comment_sheet_closes', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); + final comments = FakeCommentRepository(countResult: 97); + await _pumpApp(tester, commentRepository: comments); + await _openSeriesPlayer(tester); + + expect( + tester + .widget(find.byKey(const Key('player-rail-comments-count'))) + .data, + '96', + ); + + await tester.tap(find.byKey(const Key('player-rail-comments'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(comments.countCalls, ['city-lights-demo']); + expect( + tester + .widget(find.byKey(const Key('player-rail-comments-count'))) + .data, + '97', + ); + }); + + testWidgets('should_update_home_feed_favorite_count_after_toggle', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); + final catalog = FakeCatalogRepository(dramas: [_countingDrama]); + await _pumpApp(tester, catalog: catalog); + + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '2', + ); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '3', + ); + }); + testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -1211,6 +1273,7 @@ Future _pumpApp( FakeCatalogRepository? catalog, FakePlaybackRepository? playback, FakeInteractionRepository? interactions, + FakeCommentRepository? commentRepository, PlayerFactory? playerFactory, }) async { tester.view.physicalSize = size; @@ -1226,7 +1289,7 @@ Future _pumpApp( watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), playbackRepository: playback ?? FakePlaybackRepository(), - commentRepository: FakeCommentRepository(), + commentRepository: commentRepository ?? FakeCommentRepository(), interactionRepository: interactions ?? FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), @@ -1235,6 +1298,27 @@ Future _pumpApp( await tester.pumpAndSettle(); } +const _countingDrama = Drama( + id: 'counting-demo', + title: 'Counting Demo', + synopsis: 'Counting interaction totals', + posterUrl: '/files/counting-demo.png', + genre: 'Romance', + detailLoaded: true, + favoriteCount: 2, + commentCount: 3, + likeCount: 4, + episodes: [ + Episode( + id: 'counting-demo-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/counting-1.mp4', + ), + ], +); + class _RecordingPlayer implements PlayerPort { bool playing = false; diff --git a/test/support/fake_interaction_repositories.dart b/test/support/fake_interaction_repositories.dart index 51ebf04..b96cc52 100644 --- a/test/support/fake_interaction_repositories.dart +++ b/test/support/fake_interaction_repositories.dart @@ -42,6 +42,8 @@ class FakeCommentRepository implements CommentRepository { this.publishError, this.deleteError, this.pageError, + this.countResult = 0, + this.countError, }) : pages = pages ?? const []; final List pages; @@ -49,9 +51,12 @@ class FakeCommentRepository implements CommentRepository { final Object? publishError; final Object? deleteError; final Object? pageError; + final int countResult; + final Object? countError; final List pageCalls = []; final List publishCalls = []; final List deleteCalls = []; + final List countCalls = []; int _pageIndex = 0; @override @@ -75,6 +80,14 @@ class FakeCommentRepository implements CommentRepository { return pages[_pageIndex++]; } + @override + Future count(String dramaId) async { + countCalls.add(dramaId); + final error = countError; + if (error != null) throw error; + return countResult; + } + @override Future publish({ required String dramaId, From 7862e0be09cfd73f4cd0dc4db893e427742bbfca Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 11:52:59 +0800 Subject: [PATCH 12/49] =?UTF-8?q?fix(player):=20=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=8C=89=E5=8F=82=E8=80=83=E5=9B=BE=E9=87=8D?= =?UTF-8?q?=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/comment_tile.dart | 158 ++++++------ .../player/widgets/drama_comments_sheet.dart | 233 +++++++++--------- test/comment/drama_comments_sheet_test.dart | 126 ++++++---- 3 files changed, 273 insertions(+), 244 deletions(-) diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 3c567f4..3b17b3c 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/comment.dart'; -import '../../../core/data/services/comment_controller.dart'; import '../../../core/formatting/localized_date_time.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -17,13 +16,11 @@ const String _reportAction = 'report'; class CommentInteractionHandlers { const CommentInteractionHandlers({ required this.onReply, - required this.onLike, required this.onDelete, required this.onReport, }); final ValueChanged onReply; - final Future Function(DramaComment) onLike; final Future Function(DramaComment) onDelete; final Future Function(DramaComment) onReport; } @@ -33,13 +30,11 @@ class CommentTile extends StatelessWidget { const CommentTile({ super.key, required this.comment, - required this.controller, required this.handlers, this.isReply = false, }); final DramaComment comment; - final CommentController controller; final CommentInteractionHandlers handlers; final bool isReply; @@ -51,66 +46,68 @@ class CommentTile extends StatelessWidget { : comment.nickname; return Padding( key: Key('comment-item-${comment.id}'), - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.only(top: 12, bottom: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _avatar(), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( name, style: const TextStyle( - color: AppColors.cream, - fontSize: 13, - fontWeight: FontWeight.w700, + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w400, ), ), ), - if (comment.createdAt != null) - Text( - formatLocalizedDateTime(context, comment.createdAt!), - style: const TextStyle( + SizedBox.square( + dimension: 28, + child: PopupMenuButton( + key: Key('comment-menu-${comment.id}'), + tooltip: l10n.more, + padding: EdgeInsets.zero, + iconSize: 18, + icon: const Icon( + Icons.more_horiz_rounded, color: AppColors.subtle, - fontSize: 11, ), - ), - const SizedBox(width: 4), - PopupMenuButton( - key: Key('comment-menu-${comment.id}'), - tooltip: l10n.more, - onSelected: (value) { - if (value == _deleteAction) { - handlers.onDelete(comment); - } else if (value == _reportAction) { - handlers.onReport(comment); - } - }, - itemBuilder: (_) => [ - if (comment.mine) + onSelected: (value) { + if (value == _deleteAction) { + handlers.onDelete(comment); + } else if (value == _reportAction) { + handlers.onReport(comment); + } + }, + itemBuilder: (_) => [ + if (comment.mine) + PopupMenuItem( + value: _deleteAction, + child: Text(l10n.deleteComment), + ), PopupMenuItem( - value: _deleteAction, - child: Text(l10n.deleteComment), + value: _reportAction, + child: Text(l10n.reportComment), ), - PopupMenuItem( - value: _reportAction, - child: Text(l10n.reportComment), - ), - ], + ], + ), ), ], ), + const SizedBox(height: 2), Text( comment.content, style: const TextStyle( color: AppColors.cream, - fontSize: 14, + fontSize: 16, height: 1.4, ), ), @@ -126,48 +123,11 @@ class CommentTile extends StatelessWidget { ), ), ), - Row( - children: [ - TextButton.icon( - key: Key('comment-like-${comment.id}'), - style: TextButton.styleFrom( - foregroundColor: AppColors.muted, - padding: const EdgeInsets.symmetric(horizontal: 4), - minimumSize: const Size(0, 28), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: controller.likingComment(comment.id) - ? null - : () => handlers.onLike(comment), - icon: Icon( - comment.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - size: 16, - ), - label: Text( - '${comment.likeCount}', - style: const TextStyle(fontSize: 11), - ), - ), - if (!isReply) - TextButton( - key: Key('comment-reply-${comment.id}'), - style: TextButton.styleFrom( - foregroundColor: AppColors.blue, - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - minimumSize: const Size(0, 28), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () => handlers.onReply(comment), - child: Text(l10n.reply), - ), - ], - ), + if (comment.createdAt != null || !isReply) + Padding( + padding: const EdgeInsets.only(top: 6), + child: _metadata(context, l10n), + ), ], ), ), @@ -177,14 +137,14 @@ class CommentTile extends StatelessWidget { } Widget _avatar() { - final size = isReply ? 24.0 : 32.0; + final size = isReply ? 28.0 : 40.0; if (comment.avatarUrl.isEmpty) { return CircleAvatar( radius: size / 2, backgroundColor: AppColors.raised, child: Icon( Icons.person_rounded, - size: isReply ? 14 : 18, + size: isReply ? 16 : 22, color: AppColors.cream, ), ); @@ -197,4 +157,40 @@ class CommentTile extends StatelessWidget { ), ); } + + Widget _metadata(BuildContext context, AppLocalizations l10n) { + final createdAt = comment.createdAt; + return Row( + children: [ + if (createdAt != null) + Text( + formatLocalizedDateTime(context, createdAt), + style: const TextStyle(color: AppColors.subtle, fontSize: 12), + ), + if (createdAt != null && !isReply) ...[ + const SizedBox(width: 8), + const Text( + '·', + style: TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(width: 8), + ], + if (!isReply) + TextButton( + key: Key('comment-reply-${comment.id}'), + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + padding: EdgeInsets.zero, + minimumSize: const Size(0, 24), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => handlers.onReply(comment), + child: Text( + l10n.reply, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w400), + ), + ), + ], + ); + } } diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index c3b4c34..51e6ab1 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -23,12 +23,14 @@ import 'comment_tile.dart'; const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; -const double _composerRadius = 12; -const double _composerHeight = 52; -const double _sendButtonSize = 40; +const double _composerRadius = 22; +const double _composerHeight = 44; +const double _sendButtonSize = 38; const double _sheetRadius = 20; +const double _headerHeight = 44; +const double _replyIndent = 52; -/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 +/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、举报和本人删除。 class DramaCommentsSheet extends StatefulWidget { const DramaCommentsSheet({super.key, required this.drama}); @@ -98,17 +100,6 @@ class _DramaCommentsSheetState extends State { _inputFocus.requestFocus(); } - Future _like(DramaComment comment) async { - if (!await ensureSignedIn(context) || !mounted) return; - unawaited(context.read().lightImpact()); - final result = await _controller.toggleLike(comment); - if (!result.isSuccess && mounted) { - _showMessage( - result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed, - ); - } - } - Future _confirmDelete(DramaComment comment) async { final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( @@ -203,48 +194,40 @@ class _DramaCommentsSheetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - key: const Key('player-comments-drag-handle'), - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppColors.border, - borderRadius: BorderRadius.circular(999), - ), - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( + SizedBox( + height: _headerHeight, + width: double.infinity, + child: Stack( + alignment: Alignment.center, + children: [ + Text( count == null ? l10n.comments : l10n.commentsWithCount(count), - key: count == null ? null : const Key('comment-count-label'), + key: const Key('comment-count-label'), + textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w700, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - ), - IconButton( - key: const Key('player-comments-close'), - tooltip: l10n.close, - onPressed: () => Navigator.pop(context), - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.close_rounded, size: 20), - ), - ], - ), - Text( - widget.drama.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 12, - fontWeight: FontWeight.w500, + Positioned( + left: 0, + child: SizedBox.square( + dimension: 44, + child: IconButton( + key: const Key('player-comments-close'), + tooltip: l10n.close, + padding: EdgeInsets.zero, + onPressed: () => Navigator.pop(context), + icon: const Icon( + Icons.keyboard_arrow_down_rounded, + size: 24, + color: AppColors.cream, + ), + ), + ), + ), + ], ), ), const SizedBox(height: 8), @@ -290,7 +273,6 @@ class _DramaCommentsSheetState extends State { controller: _controller, handlers: CommentInteractionHandlers( onReply: _beginReply, - onLike: _like, onDelete: _confirmDelete, onReport: _report, ), @@ -349,9 +331,7 @@ class _DramaCommentsSheetState extends State { hintText: hint, hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), counterText: '', - filled: true, - fillColor: AppColors.commentInput, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), isDense: true, border: clear, enabledBorder: clear, @@ -361,48 +341,56 @@ class _DramaCommentsSheetState extends State { Widget _composer(AppLocalizations l10n) { final signedIn = context.watch().isSignedIn; - return Row( - children: [ - Expanded( - child: SizedBox( - height: _composerHeight, - child: TextField( - key: const Key('comment-input'), - controller: _input, - focusNode: _inputFocus, - readOnly: !signedIn, - canRequestFocus: signedIn, - maxLength: _maxCommentLength, - maxLines: 1, - textAlignVertical: TextAlignVertical.center, - style: const TextStyle(color: AppColors.cream, fontSize: 14), - cursorColor: AppColors.blue, - decoration: _composerDecoration( - signedIn ? l10n.writeComment : l10n.commentLoginHint, + return Container( + key: const Key('comment-composer'), + height: _composerHeight, + padding: const EdgeInsets.only(right: 3), + decoration: BoxDecoration( + color: AppColors.commentInput, + borderRadius: BorderRadius.circular(_composerRadius), + ), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: _composerHeight, + child: TextField( + key: const Key('comment-input'), + controller: _input, + focusNode: _inputFocus, + readOnly: !signedIn, + canRequestFocus: signedIn, + maxLength: _maxCommentLength, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle(color: AppColors.cream, fontSize: 14), + cursorColor: AppColors.blue, + decoration: _composerDecoration( + signedIn ? l10n.writeComment : l10n.commentLoginHint, + ), + onTap: signedIn ? null : _authenticateForComment, + onSubmitted: (_) => _post(), ), - onTap: signedIn ? null : _authenticateForComment, - onSubmitted: (_) => _post(), ), ), - ), - const SizedBox(width: 8), - SizedBox.square( - dimension: _sendButtonSize, - child: IconButton.filled( - key: const Key('comment-post'), - tooltip: l10n.postComment, - padding: EdgeInsets.zero, - iconSize: 18, - onPressed: _controller.submitting ? null : _post, - icon: _controller.submitting - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send_rounded), + SizedBox.square( + dimension: _sendButtonSize, + child: IconButton.filled( + key: const Key('comment-post'), + tooltip: l10n.postComment, + padding: EdgeInsets.zero, + iconSize: 18, + onPressed: _controller.submitting ? null : _post, + icon: _controller.submitting + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send_rounded), + ), ), - ), - ], + ], + ), ); } } @@ -425,39 +413,51 @@ class _CommentThread extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CommentTile( - comment: comment, - controller: controller, - handlers: handlers, - ), + CommentTile(comment: comment, handlers: handlers), if (comment.replyCount > 0 || replies.isNotEmpty) Padding( - padding: const EdgeInsets.only(left: 42, bottom: 4), + padding: const EdgeInsets.only(left: _replyIndent, bottom: 4), child: TextButton( key: Key('comment-replies-${comment.id}'), onPressed: () => _toggleReplies(context), style: TextButton.styleFrom( - foregroundColor: AppColors.blue, - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - minimumSize: const Size(0, 28), + foregroundColor: AppColors.muted, + padding: EdgeInsets.zero, + minimumSize: const Size(0, 24), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - child: Text( - expanded - ? AppLocalizations.of(context)!.hideReplies - : AppLocalizations.of( - context, - )!.viewReplies(comment.replyCount), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 16, height: 1, color: AppColors.border), + const SizedBox(width: 8), + Text( + expanded + ? AppLocalizations.of(context)!.hideReplies + : AppLocalizations.of( + context, + )!.viewReplies(comment.replyCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 4), + Icon( + expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: AppColors.muted, + ), + ], ), ), ), if (expanded) Padding( - padding: const EdgeInsets.only(left: 28), + padding: const EdgeInsets.only(left: _replyIndent), child: Column( children: [ if (controller.repliesLoading(comment.id)) @@ -468,7 +468,6 @@ class _CommentThread extends StatelessWidget { for (final reply in replies) CommentTile( comment: reply, - controller: controller, handlers: handlers, isReply: true, ), @@ -480,10 +479,6 @@ class _CommentThread extends StatelessWidget { ], ), ), - const Padding( - padding: EdgeInsets.only(left: 42), - child: Divider(height: 1, color: AppColors.border), - ), ], ); } diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 9e8cff8..4a256dc 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -129,7 +129,10 @@ void main() { await _pumpSheet(tester, repository: repository); expect(find.text('Great episode'), findsOneWidget); expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget); - expect(find.byKey(const Key('comment-count-label')), findsNothing); + expect( + tester.widget(find.byKey(const Key('comment-count-label'))).data, + 'Comments', + ); await tester.tap(find.byKey(const Key('comment-list-load-more'))); await tester.pumpAndSettle(); @@ -209,30 +212,89 @@ void main() { expect( tester.getSize(find.byKey(const Key('comment-post'))), - const Size(40, 40), + const Size(38, 38), ); - expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 52); + expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44); + expect(find.byKey(const Key('player-comments-drag-handle')), findsNothing); expect( - find.byKey(const Key('player-comments-drag-handle')), + tester.getSize(find.byKey(const Key('player-comments-close'))), + const Size(44, 44), + ); + expect( + find.descendant( + of: find.byKey(const Key('player-comments-close')), + matching: find.byIcon(Icons.keyboard_arrow_down_rounded), + ), findsOneWidget, ); + final title = tester.widget( + find.byKey(const Key('comment-count-label')), + ); + expect(title.style?.fontSize, 16); + expect(title.style?.fontWeight, FontWeight.w600); + expect( + tester.getCenter(find.byKey(const Key('comment-count-label'))).dx, + closeTo( + tester.getCenter(find.byKey(const Key('player-comments-sheet'))).dx, + 0.5, + ), + ); final avatar = tester.widget( find.descendant( of: find.byKey(const Key('comment-item-10')), matching: find.byType(CircleAvatar), ), ); - expect(avatar.radius, 16); + expect(avatar.radius, 20); final body = tester.widget(find.text('Great episode')); - expect(body.style?.fontSize, 14); + expect(body.style?.fontSize, 16); expect(body.style?.height, 1.4); - expect( - tester - .widget(find.byKey(const Key('comment-input'))) - .decoration - ?.fillColor, - AppColors.commentInput, + final composer = tester.widget( + find.byKey(const Key('comment-composer')), ); + final decoration = composer.decoration! as BoxDecoration; + expect(decoration.color, AppColors.commentInput); + expect(decoration.borderRadius, BorderRadius.circular(22)); + expect( + find.descendant( + of: find.byKey(const Key('comment-menu-10')), + matching: find.byIcon(Icons.more_horiz_rounded), + ), + findsOneWidget, + ); + }); + + testWidgets('should_hide_comment_like_controls', (tester) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'No likes shown')], + ), + ], + ); + + await _pumpSheet(tester, repository: repository); + + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith('comment-like-'), + ), + findsNothing, + ); + expect(find.byIcon(Icons.favorite_rounded), findsNothing); + expect(find.byIcon(Icons.favorite_border_rounded), findsNothing); + }); + + testWidgets('should_close_sheet_from_down_arrow', (tester) async { + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('player-comments-close'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-comments-sheet')), findsNothing); }); testWidgets('should_show_review_badge_and_server_replaced_content', ( @@ -306,34 +368,6 @@ void main() { expect(find.byKey(const Key('comment-list-retry')), findsOneWidget); }); - testWidgets('should_update_comment_like_count_when_server_returns_count', ( - tester, - ) async { - final repository = FakeCommentRepository( - pages: [ - CommentPage( - comments: [commentFixture(id: '10', content: 'Like me')], - ), - ], - ); - final interactions = FakeInteractionRepository() - ..nextLikeResult = const LikeToggleResult(liked: true, count: 7); - final haptics = FakeHaptics(); - - await _pumpSheet( - tester, - repository: repository, - interactions: interactions, - haptics: haptics, - ); - await tester.tap(find.byKey(const Key('comment-like-10'))); - await tester.pumpAndSettle(); - - expect(interactions.likeTargets, ['COMMENT:10']); - expect(haptics.lightImpacts, 1); - expect(find.text('7'), findsOneWidget); - }); - testWidgets('should_report_comment_and_show_success_when_submitted', ( tester, ) async { @@ -480,10 +514,14 @@ Future _pumpSheet( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( - backgroundColor: AppColors.ink, - body: DramaCommentsSheet(drama: _drama), - ), + initialRoute: '/comments', + routes: { + '/': (_) => const Scaffold(backgroundColor: AppColors.ink), + '/comments': (_) => const Scaffold( + backgroundColor: AppColors.ink, + body: DramaCommentsSheet(drama: _drama), + ), + }, ), ), ); From 2659c2950164bfc35c8a9fa364d60a7432f5128a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 12:00:27 +0800 Subject: [PATCH 13/49] =?UTF-8?q?fix(player):=20=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E6=80=BB=E6=95=B0=E5=90=AB=E5=9B=9E=E5=A4=8D=E5=B9=B6=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E7=9B=B8=E5=AF=B9=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/formatting/relative_date_time.dart | 24 ++++++++ lib/l10n/app_en.arb | 7 +++ lib/l10n/app_id.arb | 7 +++ lib/l10n/app_localizations.dart | 12 ++++ lib/l10n/app_localizations_en.dart | 8 +++ lib/l10n/app_localizations_id.dart | 8 +++ lib/l10n/app_localizations_zh.dart | 8 +++ lib/l10n/app_zh.arb | 7 +++ lib/pages/player/widgets/comment_tile.dart | 4 +- .../player/widgets/drama_comments_sheet.dart | 27 ++++++++- test/comment/drama_comments_sheet_test.dart | 41 +++++++++++++ test/formatting/relative_date_time_test.dart | 57 +++++++++++++++++++ test/player/player_fullscreen_test.dart | 3 +- .../fake_interaction_repositories.dart | 8 ++- 14 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 lib/core/formatting/relative_date_time.dart create mode 100644 test/formatting/relative_date_time_test.dart diff --git a/lib/core/formatting/relative_date_time.dart b/lib/core/formatting/relative_date_time.dart new file mode 100644 index 0000000..1040824 --- /dev/null +++ b/lib/core/formatting/relative_date_time.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import '../../l10n/app_localizations.dart'; + +/// Formats a timestamp as a relative label, falling back to a compact local date. +String formatRelativeDateTime( + DateTime value, + AppLocalizations l10n, { + DateTime? now, +}) { + final localValue = value.toLocal(); + final localNow = (now ?? DateTime.now()).toLocal(); + final elapsed = localNow.difference(localValue); + if (elapsed.isNegative || elapsed.inMinutes < 1) return l10n.justNow; + if (elapsed.inMinutes < 60) return l10n.minutesAgo(elapsed.inMinutes); + if (elapsed.inHours < 24) return l10n.hoursAgo(elapsed.inHours); + if (localValue.year == localNow.year) { + return '${_twoDigits(localValue.month)}-${_twoDigits(localValue.day)}'; + } + return '${localValue.year}-${_twoDigits(localValue.month)}-' + '${_twoDigits(localValue.day)}'; +} + +String _twoDigits(int value) => value.toString().padLeft(2, '0'); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6affe0c..7fc3372 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -100,6 +100,13 @@ "count": {"type": "int"} } }, + "justNow": "Just now", + "hoursAgo": "{count} hours ago", + "@hoursAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 68f8893..24ab80c 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -79,6 +79,13 @@ "count": {"type": "int"} } }, + "justNow": "Baru saja", + "hoursAgo": "{count} jam lalu", + "@hoursAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 650b5bb..0e0842b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -508,6 +508,18 @@ abstract class AppLocalizations { /// **'{count} min ago'** String minutesAgo(int count); + /// No description provided for @justNow. + /// + /// In en, this message translates to: + /// **'Just now'** + String get justNow; + + /// No description provided for @hoursAgo. + /// + /// In en, this message translates to: + /// **'{count} hours ago'** + String hoursAgo(int count); + /// No description provided for @artworkOnly. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6437937..b043759 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -227,6 +227,14 @@ class AppLocalizationsEn extends AppLocalizations { return '$count min ago'; } + @override + String get justNow => 'Just now'; + + @override + String hoursAgo(int count) { + return '$count hours ago'; + } + @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5b3e823..ae57fa3 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -227,6 +227,14 @@ class AppLocalizationsId extends AppLocalizations { return '$count mnt lalu'; } + @override + String get justNow => 'Baru saja'; + + @override + String hoursAgo(int count) { + return '$count jam lalu'; + } + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 30037c8..b449f10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -224,6 +224,14 @@ class AppLocalizationsZh extends AppLocalizations { return '$count 分钟前'; } + @override + String get justNow => '刚刚'; + + @override + String hoursAgo(int count) { + return '$count 小时前'; + } + @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8ceaa1d..5b4b709 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -79,6 +79,13 @@ "count": {"type": "int"} } }, + "justNow": "刚刚", + "hoursAgo": "{count} 小时前", + "@hoursAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 3b17b3c..d4016cb 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/comment.dart'; -import '../../../core/formatting/localized_date_time.dart'; +import '../../../core/formatting/relative_date_time.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/remote_image.dart'; @@ -164,7 +164,7 @@ class CommentTile extends StatelessWidget { children: [ if (createdAt != null) Text( - formatLocalizedDateTime(context, createdAt), + formatRelativeDateTime(createdAt, l10n), style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), if (createdAt != null && !isReply) ...[ diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 51e6ab1..70d6c0c 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -43,18 +43,23 @@ class DramaCommentsSheet extends StatefulWidget { class _DramaCommentsSheetState extends State { final _input = TextEditingController(); final _inputFocus = FocusNode(); + late final CommentRepository _comments; late final CommentController _controller; DramaComment? _replyTo; + int? _serverCount; + int _countRequest = 0; @override void initState() { super.initState(); + _comments = context.read(); _controller = CommentController( - comments: context.read(), + comments: _comments, interactions: context.read(), dramaId: widget.drama.id, ); unawaited(_controller.load()); + unawaited(_refreshCount()); } @override @@ -76,6 +81,7 @@ class _DramaCommentsSheetState extends State { _input.clear(); setState(() => _replyTo = null); FocusScope.of(context).unfocus(); + await _refreshCount(); return; } _showMessage( @@ -122,11 +128,26 @@ class _DramaCommentsSheetState extends State { ); if (confirmed != true || !mounted) return; final result = await _controller.delete(comment); - if (!result.isSuccess && mounted) { + if (!result.isSuccess) { + if (!mounted) return; _showMessage( result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed, ); + return; + } + await _refreshCount(); + } + + Future _refreshCount() async { + final request = ++_countRequest; + try { + final count = await _comments.count(widget.drama.id); + if (!mounted || request != _countRequest) return; + setState(() => _serverCount = count); + } catch (_) { + if (!mounted || request != _countRequest) return; + setState(() => _serverCount = null); } } @@ -190,7 +211,7 @@ class _DramaCommentsSheetState extends State { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final count = _controller.exactCount; + final count = _serverCount ?? _controller.exactCount; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 4a256dc..fc8bf32 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -142,6 +142,45 @@ void main() { expect(repository.pageCalls.last.cursor, '10'); }); + testWidgets('should_show_server_total_that_includes_replies', (tester) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [ + commentFixture(id: '10', content: 'Root one'), + commentFixture(id: '11', content: 'Root two'), + ], + ), + ], + countResult: 9, + ); + + await _pumpSheet(tester, repository: repository); + + expect(find.text('9 comments'), findsOneWidget); + expect(repository.countCalls, ['5']); + }); + + testWidgets('should_fall_back_to_exact_count_when_server_total_fails', ( + tester, + ) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [ + commentFixture(id: '10', content: 'Root one'), + commentFixture(id: '11', content: 'Root two'), + ], + ), + ], + countError: StateError('offline'), + ); + + await _pumpSheet(tester, repository: repository); + + expect(find.text('2 comments'), findsOneWidget); + }); + testWidgets('should_show_compact_empty_state_when_no_comments_exist', ( tester, ) async { @@ -185,6 +224,7 @@ void main() { final repository = FakeCommentRepository( pages: [const CommentPage()], publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true), + countResult: 1, ); await _pumpSheet(tester, repository: repository); @@ -197,6 +237,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.widget(input).focusNode?.hasFocus, isFalse); + expect(repository.countCalls, ['5', '5']); }); testWidgets('should_use_polished_comment_sizes', (tester) async { diff --git a/test/formatting/relative_date_time_test.dart b/test/formatting/relative_date_time_test.dart new file mode 100644 index 0000000..6bcc9a5 --- /dev/null +++ b/test/formatting/relative_date_time_test.dart @@ -0,0 +1,57 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/formatting/relative_date_time.dart'; +import 'package:fruitshorts/l10n/app_localizations_en.dart'; + +void main() { + final l10n = AppLocalizationsEn(); + final now = DateTime(2026, 10, 21, 12); + + test('should_show_just_now_under_one_minute', () { + expect( + formatRelativeDateTime( + now.subtract(const Duration(seconds: 59)), + l10n, + now: now, + ), + 'Just now', + ); + }); + + test('should_use_minutes_below_sixty_minutes', () { + expect( + formatRelativeDateTime( + now.subtract(const Duration(minutes: 59)), + l10n, + now: now, + ), + '59 min ago', + ); + }); + + test('should_use_hours_below_twenty_four_hours', () { + expect( + formatRelativeDateTime( + now.subtract(const Duration(hours: 23)), + l10n, + now: now, + ), + '23 hours ago', + ); + }); + + test('should_use_month_and_day_for_same_year', () { + expect( + formatRelativeDateTime(DateTime(2026, 9, 3), l10n, now: now), + '09-03', + ); + }); + + test('should_use_full_date_for_previous_year', () { + expect( + formatRelativeDateTime(DateTime(2025, 12, 31), l10n, now: now), + '2025-12-31', + ); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 4c809b0..9ee28af 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -198,11 +198,12 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); + expect(find.text('97 comments'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(comments.countCalls, ['city-lights-demo']); + expect(comments.countCalls, ['city-lights-demo', 'city-lights-demo']); expect( tester .widget(find.byKey(const Key('player-rail-comments-count'))) diff --git a/test/support/fake_interaction_repositories.dart b/test/support/fake_interaction_repositories.dart index b96cc52..071a33b 100644 --- a/test/support/fake_interaction_repositories.dart +++ b/test/support/fake_interaction_repositories.dart @@ -42,7 +42,7 @@ class FakeCommentRepository implements CommentRepository { this.publishError, this.deleteError, this.pageError, - this.countResult = 0, + this.countResult, this.countError, }) : pages = pages ?? const []; @@ -51,7 +51,7 @@ class FakeCommentRepository implements CommentRepository { final Object? publishError; final Object? deleteError; final Object? pageError; - final int countResult; + final int? countResult; final Object? countError; final List pageCalls = []; final List publishCalls = []; @@ -85,7 +85,9 @@ class FakeCommentRepository implements CommentRepository { countCalls.add(dramaId); final error = countError; if (error != null) throw error; - return countResult; + final result = countResult; + if (result == null) throw StateError('comment count unavailable'); + return result; } @override From ab476cab7d9a05d1d2a0e46325cd91cc1fbb0be6 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 12:07:30 +0800 Subject: [PATCH 14/49] =?UTF-8?q?fix(player):=20=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=87=AA=E9=80=82=E5=BA=94=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=E5=B9=B6=E5=8F=AA=E9=A1=B6=E8=B5=B7=E8=BE=93=E5=85=A5=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/widgets/comment_tile.dart | 4 +- .../player/widgets/drama_comments_sheet.dart | 255 +++++++++++------- test/comment/drama_comments_sheet_test.dart | 106 +++++++- 3 files changed, 266 insertions(+), 99 deletions(-) diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index d4016cb..bfc2719 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -46,7 +46,7 @@ class CommentTile extends StatelessWidget { : comment.nickname; return Padding( key: Key('comment-item-${comment.id}'), - padding: const EdgeInsets.only(top: 12, bottom: 14), + padding: const EdgeInsets.only(top: 10, bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -125,7 +125,7 @@ class CommentTile extends StatelessWidget { ), if (comment.createdAt != null || !isReply) Padding( - padding: const EdgeInsets.only(top: 6), + padding: const EdgeInsets.only(top: 4), child: _metadata(context, l10n), ), ], diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 70d6c0c..33dc0bc 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -29,6 +29,8 @@ const double _sendButtonSize = 38; const double _sheetRadius = 20; const double _headerHeight = 44; const double _replyIndent = 52; +const double _composerTopGap = 8; +const double _replyBannerHeight = 46; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、举报和本人删除。 class DramaCommentsSheet extends StatefulWidget { @@ -178,114 +180,170 @@ class _DramaCommentsSheetState extends State { @override Widget build(BuildContext context) { - final available = - MediaQuery.sizeOf(context).height - - MediaQuery.viewInsetsOf(context).bottom - - 24; - return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), - child: Container( - key: const Key('player-comments-sheet'), - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical( - top: Radius.circular(_sheetRadius), - ), - ), - child: SizedBox( - height: available.clamp(_minSheetHeight, _maxSheetHeight), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) => _content(context), + final screenLimit = MediaQuery.sizeOf(context).height - 24; + final maxHeight = screenLimit < _maxSheetHeight + ? screenLimit + : _maxSheetHeight; + return Container( + key: const Key('player-comments-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final minHeight = _controller.comments.isEmpty + ? (_minSheetHeight < maxHeight ? _minSheetHeight : maxHeight) + : 0.0; + return ConstrainedBox( + key: const Key('comment-sheet-constraints'), + constraints: BoxConstraints( + minHeight: minHeight, + maxHeight: maxHeight, ), - ), - ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _content(context, maxHeight), + ), + ); + }, ), ), ); } - Widget _content(BuildContext context) { + Widget _content(BuildContext context, double maxHeight) { final l10n = AppLocalizations.of(context)!; final count = _serverCount ?? _controller.exactCount; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + final replyReserve = _replyTo == null ? 0.0 : _replyBannerHeight; + final listMaxHeight = + maxHeight - + _headerHeight - + _composerHeight - + _composerTopGap - + replyReserve; + return Stack( children: [ - SizedBox( - height: _headerHeight, - width: double.infinity, - child: Stack( - alignment: Alignment.center, - children: [ - Text( - count == null ? l10n.comments : l10n.commentsWithCount(count), - key: const Key('comment-count-label'), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - Positioned( - left: 0, - child: SizedBox.square( - dimension: 44, - child: IconButton( - key: const Key('player-comments-close'), - tooltip: l10n.close, - padding: EdgeInsets.zero, - onPressed: () => Navigator.pop(context), - icon: const Icon( - Icons.keyboard_arrow_down_rounded, - size: 24, - color: AppColors.cream, + Column( + key: const Key('comment-sheet-content'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: _headerHeight, + width: double.infinity, + child: Stack( + alignment: Alignment.center, + children: [ + Text( + count == null + ? l10n.comments + : l10n.commentsWithCount(count), + key: const Key('comment-count-label'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - ), + Positioned( + left: 0, + child: SizedBox.square( + dimension: 44, + child: IconButton( + key: const Key('player-comments-close'), + tooltip: l10n.close, + padding: EdgeInsets.zero, + onPressed: () => Navigator.pop(context), + icon: const Icon( + Icons.keyboard_arrow_down_rounded, + size: 24, + color: AppColors.cream, + ), + ), + ), + ), + ], ), - ], + ), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: listMaxHeight < 0 ? 0 : listMaxHeight, + ), + child: _list(l10n), + ), + if (_replyTo != null) _replyBanner(l10n, _replyTo!), + const SizedBox( + height: _composerHeight + _composerTopGap, + ), + ], + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedPadding( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: Padding( + padding: const EdgeInsets.only(top: _composerTopGap), + child: _composer(l10n), + ), ), ), - const SizedBox(height: 8), - Expanded(child: _list(l10n)), - if (_replyTo != null) _replyBanner(l10n, _replyTo!), - _composer(l10n), ], ); } Widget _list(AppLocalizations l10n) { + final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom; if (_controller.loading && _controller.comments.isEmpty) { - return const Center( - child: CircularProgressIndicator(key: Key('comment-list-loading')), + return const Padding( + padding: EdgeInsets.only(top: 4), + child: Center( + child: CircularProgressIndicator(key: Key('comment-list-loading')), + ), ); } if (_controller.error != null && _controller.comments.isEmpty) { - return EmptyState( - key: const Key('comment-list-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, _controller.error, l10n.commentsLoadFailed), - actionLabel: l10n.retry, - actionKey: const Key('comment-list-retry'), - onAction: _controller.load, - compact: true, + return Padding( + padding: const EdgeInsets.only(top: 4), + child: EmptyState( + key: const Key('comment-list-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, _controller.error, l10n.commentsLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('comment-list-retry'), + onAction: _controller.load, + compact: true, + ), ); } if (_controller.comments.isEmpty) { - return EmptyState( - key: const Key('comment-list-empty'), - icon: Icons.chat_bubble_outline_rounded, - title: l10n.noComments, - compact: true, + return Padding( + padding: const EdgeInsets.only(top: 4), + child: EmptyState( + key: const Key('comment-list-empty'), + icon: Icons.chat_bubble_outline_rounded, + title: l10n.noComments, + compact: true, + ), ); } return ListView.builder( key: const Key('comment-list'), + shrinkWrap: true, + padding: EdgeInsets.only( + top: 4, + bottom: _composerHeight + keyboardHeight, + ), itemCount: _controller.comments.length + 1, itemBuilder: (context, index) { if (index == _controller.comments.length) return _footer(l10n); @@ -319,26 +377,31 @@ class _DramaCommentsSheetState extends State { return const SizedBox(height: 8); } - Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - children: [ - Expanded( - child: Text( - l10n.replyingTo( - replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname, + Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => SizedBox( + height: _replyBannerHeight, + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Expanded( + child: Text( + l10n.replyingTo( + replyTo.nickname.isEmpty + ? l10n.anonymousUser + : replyTo.nickname, + ), + style: const TextStyle(color: AppColors.muted, fontSize: 12), ), - style: const TextStyle(color: AppColors.muted, fontSize: 12), ), - ), - IconButton( - key: const Key('comment-cancel-reply'), - tooltip: l10n.cancelReply, - onPressed: _cancelReply, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.close_rounded, size: 18), - ), - ], + IconButton( + key: const Key('comment-cancel-reply'), + tooltip: l10n.cancelReply, + onPressed: _cancelReply, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close_rounded, size: 18), + ), + ], + ), ), ); diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index fc8bf32..fb64efc 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -305,6 +305,101 @@ void main() { ); }); + testWidgets('should_size_short_list_to_content', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [ + commentFixture(id: '10', content: 'Short list one'), + commentFixture(id: '11', content: 'Short list two'), + ], + ), + ], + ); + + await _pumpSheet(tester, repository: repository); + + final sheet = find.byKey(const Key('player-comments-sheet')); + expect(tester.getSize(sheet).height, lessThan(520)); + final lastCommentBottom = tester + .getBottomLeft(find.byKey(const Key('comment-item-11'))) + .dy; + final composerTop = tester + .getTopLeft(find.byKey(const Key('comment-composer'))) + .dy; + expect(composerTop - lastCommentBottom, lessThan(80)); + }); + + testWidgets('should_keep_minimum_height_for_empty_state', (tester) async { + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + + expect( + tester.getSize(find.byKey(const Key('player-comments-sheet'))).height, + greaterThanOrEqualTo(220), + ); + }); + + testWidgets('should_keep_long_comment_list_scrollable', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repository = FakeCommentRepository( + pages: [CommentPage(comments: _comments(30))], + ); + + await _pumpSheet(tester, repository: repository); + final scrolledComment = find.byKey(const Key('comment-item-12')); + final before = tester.getTopLeft(scrolledComment).dy; + + await tester.drag( + find.byKey(const Key('comment-list')), + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(scrolledComment).dy, lessThan(before)); + expect( + tester.getSize(find.byKey(const Key('player-comments-sheet'))).height, + 520, + ); + }); + + testWidgets('should_keep_sheet_fixed_and_move_only_composer_for_keyboard', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + final repository = FakeCommentRepository( + pages: [CommentPage(comments: _comments(30))], + ); + + await _pumpSheet(tester, repository: repository); + final sheet = find.byKey(const Key('player-comments-sheet')); + final input = find.byKey(const Key('comment-composer')); + final first = find.byKey(const Key('comment-item-10')); + final sheetTop = tester.getTopLeft(sheet).dy; + final inputTop = tester.getTopLeft(input).dy; + final firstTop = tester.getTopLeft(first).dy; + + tester.view.viewInsets = const FakeViewPadding(bottom: 180); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 160)); + + expect(tester.getTopLeft(sheet).dy, closeTo(sheetTop, 0.1)); + expect(tester.getTopLeft(first).dy, closeTo(firstTop, 0.1)); + expect(tester.getTopLeft(input).dy, lessThan(inputTop - 100)); + }); + testWidgets('should_hide_comment_like_controls', (tester) async { final repository = FakeCommentRepository( pages: [ @@ -560,7 +655,11 @@ Future _pumpSheet( '/': (_) => const Scaffold(backgroundColor: AppColors.ink), '/comments': (_) => const Scaffold( backgroundColor: AppColors.ink, - body: DramaCommentsSheet(drama: _drama), + resizeToAvoidBottomInset: false, + body: Align( + alignment: Alignment.bottomCenter, + child: DramaCommentsSheet(drama: _drama), + ), ), }, ), @@ -602,6 +701,11 @@ Future _auth() async { return auth; } +List _comments(int count) => [ + for (var index = 0; index < count; index++) + commentFixture(id: '${index + 10}', content: 'Comment ${index + 1}'), +]; + class _TestAuthController extends AuthController { _TestAuthController(super.api, super.store, {required this._signedIn}); From dbeb9883a5b315fee93ccf7ea50eaafee2ce33cf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 12:14:09 +0800 Subject: [PATCH 15/49] =?UTF-8?q?fix(android):=20=E9=94=AE=E7=9B=98?= =?UTF-8?q?=E5=BC=B9=E5=87=BA=E4=B8=8D=E5=86=8D=E5=8E=8B=E7=BC=A9=E6=95=B4?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 2 +- lib/pages/auth/auth_view.dart | 2 +- test/comment/drama_comments_sheet_test.dart | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2571b16..6c9f35c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustNothing">