From ea9ea6e60641fce9841c46143dfd53d6d0edd61d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 14:19:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(flutter):=20feed=20=E9=95=BF=E6=8C=89?= =?UTF-8?q?=E5=8A=A0=E9=80=9F=E4=B8=8E=E5=88=B7=E6=96=B0=E8=BD=AC=E5=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 153 +++++++++--------- .../player/widgets/home_feed_overlay.dart | 108 +++++++++++-- .../widgets/home_feed_overlay_parts.dart | 31 ++-- test/home/home_preview_layout_test.dart | 94 ++++++++++- test/home/k28_a_navigation_test.dart | 34 +++- 5 files changed, 317 insertions(+), 103 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 953e7cc..eb9a665 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -366,83 +366,82 @@ class _PlayerPageState extends State with WidgetsBindingObserver { if (logic.pip || logic.pipPreparing) const SizedBox.expand() else if (widget.homeMode) - _chromeVisible - ? HomeFeedOverlay( - l10n: l10n, - drama: drama, - episode: episode, - controller: ready ? videoPort!.controller : null, - liked: interactions.isLiked(drama.id), - likeCount: - interactions.likeCount(drama.id) ?? drama.likeCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - commentCount: drama.commentCount, - saved: context.watch().isSaved(drama.id), - onLike: () async { - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await interactions.toggleDramaLike( - drama.id, - ); - if (!result.isSuccess && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? - AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - } - }, - 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: () => logic.showComments(context, drama), - onShare: () => logic.shareDrama(context, drama), - onEpisodes: () => logic.showEpisodes(context), - onDetail: () => unawaited(logic.openDetail(context)), - onOpenFullPlayer: () async { - await widget.onOpenFullPlayer?.call(drama, episode); - if (!mounted || !widget.active) return; - logic.session.setUserWantsPlay(true); - }, - onRefreshFeed: () async { - final ok = await widget.onRefreshFeed?.call() ?? true; - if (!context.mounted || ok) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); - }, - onDownload: () => requestEpisodeDownload(context, episode.id), - onClear: () => setState(() => _chromeVisible = false), - onSearch: - widget.onSearch ?? () => logic.showDiscover(context), - onScrubStart: ready - ? () => logic.beginScrub(videoPort!) - : null, - onSeek: ready - ? (position) => - logic.seekTo(context, videoPort!, position) - : null, - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, - ) - : const SizedBox.shrink() + HomeFeedOverlay( + l10n: l10n, + drama: drama, + episode: episode, + controller: ready ? videoPort!.controller : null, + visible: _chromeVisible, + boosting: _boosting, + onBoostStart: _startBoost, + onBoostEnd: _endBoost, + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + commentCount: drama.commentCount, + saved: context.watch().isSaved(drama.id), + onLike: () async { + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? + AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + }, + 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: () => logic.showComments(context, drama), + onShare: () => logic.shareDrama(context, drama), + onEpisodes: () => logic.showEpisodes(context), + onDetail: () => unawaited(logic.openDetail(context)), + onOpenFullPlayer: () async { + await widget.onOpenFullPlayer?.call(drama, episode); + if (!mounted || !widget.active) return; + logic.session.setUserWantsPlay(true); + }, + onRefreshFeed: () async { + final ok = await widget.onRefreshFeed?.call() ?? true; + if (context.mounted && !ok) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); + } + return ok; + }, + onDownload: () => requestEpisodeDownload(context, episode.id), + onClear: () => setState(() => _chromeVisible = false), + onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + ) else PlayerPlaybackOverlay( data: PlayerOverlayData( diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index d92e950..23bd1e7 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -27,6 +27,9 @@ const double _tagFontSize = 11; const double _fullBarHeight = 44; const double _progressHeight = 2; const double _progressHitHeight = 24; +const List _boostTextShadows = [ + Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), +]; const int _maxTags = 4; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -40,6 +43,8 @@ class HomeFeedOverlay extends StatefulWidget { required this.controller, required this.liked, required this.saved, + required this.visible, + required this.boosting, required this.onLike, required this.onSave, required this.onComments, @@ -51,6 +56,9 @@ class HomeFeedOverlay extends StatefulWidget { required this.onDownload, required this.onClear, required this.onSearch, + required this.onTogglePlay, + required this.onBoostStart, + required this.onBoostEnd, this.onScrubStart, this.onSeek, this.likeCount, @@ -70,6 +78,8 @@ class HomeFeedOverlay extends StatefulWidget { final int? commentCount; final bool saved; final String authorNote; + final bool visible; + final bool boosting; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onComments; @@ -77,13 +87,16 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onEpisodes; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; - final VoidCallback onRefreshFeed; + final Future Function() onRefreshFeed; final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback onSearch; + final VoidCallback onTogglePlay; final VoidCallback? onPip; final VoidCallback? onScrubStart; final ValueChanged? onSeek; + final VoidCallback onBoostStart; + final VoidCallback onBoostEnd; @override State createState() => _HomeFeedOverlayState(); @@ -91,30 +104,100 @@ class HomeFeedOverlay extends StatefulWidget { class _HomeFeedOverlayState extends State { _FeedTab _tab = _FeedTab.recommend; + bool _refreshing = false; @override Widget build(BuildContext context) { return SafeArea( bottom: false, - child: Column( + child: Stack( children: [ - _header(context), - Expanded( - child: Align( - alignment: const Alignment(1.0, 0.68), - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), - child: _rail(), + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) => Listener( + onPointerUp: (_) => widget.onBoostEnd(), + onPointerCancel: (_) => widget.onBoostEnd(), + child: GestureDetector( + key: const Key('home-feed-gesture'), + behavior: HitTestBehavior.opaque, + onTap: widget.onTogglePlay, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => widget.onBoostEnd(), + onLongPressCancel: widget.onBoostEnd, + ), ), ), ), - _bottomInfo(), - _fullSeriesBar(), + if (widget.boosting) + Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()]) + else if (widget.visible) + Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: const Alignment(1.0, 0.80), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), + child: _rail(), + ), + ), + ), + _bottomInfo(), + _fullSeriesBar(), + ], + ), ], ), ); } + void _handleLongPressStart(LongPressStartDetails details, double width) { + final third = width / 3; + final dx = details.localPosition.dx; + if (dx < third || dx > width - third) { + widget.onBoostStart(); + return; + } + _openMore(context); + } + + Widget _boostHint() { + return Padding( + key: const Key('home-feed-speed-boost-hint'), + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: 16, + shadows: _boostTextShadows, + ), + const SizedBox(width: 4), + Text( + widget.l10n.speedBoosting('2.0'), + style: const TextStyle( + color: AppColors.blue, + fontSize: 13, + fontWeight: FontWeight.w700, + shadows: _boostTextShadows, + ), + ), + ], + ), + ); + } + + Future _refresh() async { + if (_refreshing) return; + setState(() => _refreshing = true); + await widget.onRefreshFeed(); + if (mounted) setState(() => _refreshing = false); + } + Widget _header(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), @@ -124,7 +207,8 @@ class _HomeFeedOverlayState extends State { tooltip: widget.l10n.retry, dimension: _topIconSize, glyphSize: _topIconGlyph, - onTap: widget.onRefreshFeed, + refreshing: _refreshing, + onTap: _refresh, onLongPress: () => _openMore(context), ), Expanded( diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 5e4898a..c871980 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -3,9 +3,9 @@ part of 'home_feed_overlay.dart'; const double _thumbSize = 6; -const double _thumbActiveSize = 10; -const double _trackActiveHeight = 4; -const double _lineCenterFromBottom = 6; +const double _thumbActiveSize = 14; +const double _trackActiveHeight = 6; +const double _lineCenterFromBottom = 8; /// 顶部频道 tab:漫剧点击提示即将上线。 enum _FeedTab { @@ -69,6 +69,7 @@ class _RefreshButton extends StatelessWidget { required this.tooltip, required this.dimension, required this.glyphSize, + required this.refreshing, required this.onTap, required this.onLongPress, }); @@ -76,6 +77,7 @@ class _RefreshButton extends StatelessWidget { final String tooltip; final double dimension; final double glyphSize; + final bool refreshing; final VoidCallback onTap; final VoidCallback onLongPress; @@ -90,12 +92,23 @@ class _RefreshButton extends StatelessWidget { onLongPress: onLongPress, child: SizedBox.square( dimension: dimension, - child: Icon( - Icons.refresh_rounded, - size: glyphSize, - color: Colors.white, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], - ), + child: refreshing + ? const Center( + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + key: Key('home-feed-refreshing'), + strokeWidth: 2, + color: Colors.white, + ), + ), + ) + : Icon( + Icons.refresh_rounded, + size: glyphSize, + color: Colors.white, + shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + ), ), ), ); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 62f3fd4..38bcb81 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -104,6 +106,11 @@ void main() { controller: null, liked: true, saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, likeCount: 1006, favoriteCount: 32000, commentCount: 12, @@ -114,7 +121,7 @@ void main() { onEpisodes: () {}, onDetail: () {}, onOpenFullPlayer: () {}, - onRefreshFeed: () {}, + onRefreshFeed: () async => true, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -189,6 +196,11 @@ void main() { controller: controller, liked: false, saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, @@ -196,7 +208,10 @@ void main() { onEpisodes: () {}, onDetail: () {}, onOpenFullPlayer: () => opened++, - onRefreshFeed: () => refreshed++, + onRefreshFeed: () async { + refreshed++; + return true; + }, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -254,11 +269,11 @@ void main() { await tester.pump(); expect( tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), - const Size(10, 10), + const Size(14, 14), ); expect( tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, - 4, + 6, ); expect( tester @@ -290,4 +305,75 @@ void main() { await tester.pump(); expect(opened, 1); }); + + testWidgets('should_show_refresh_spinner_until_refresh_finishes', ( + tester, + ) async { + final completer = Completer(); + var calls = 0; + 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: 'refresh-drama', + title: 'Refresh Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'refresh-episode', + number: 1, + title: 'Episode 1', + isFree: true, + ), + controller: null, + liked: false, + saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onRefreshFeed: () { + calls++; + return completer.future; + }, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-feed-refresh'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-refreshing')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-refresh'))); + await tester.pump(); + expect(calls, 1); + + completer.complete(true); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-refreshing')), findsNothing); + expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); + }); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 549714d..574b845 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -118,10 +118,42 @@ void main() { .getRect(find.byKey(const Key('home-feed-series-meta'))) .top; - expect(railTop, greaterThan(844 * 0.35)); + expect(railTop, greaterThan(844 * 0.45)); expect(railBottom, lessThanOrEqualTo(infoTop)); }); + testWidgets('should_boost_feed_on_side_long_press', (tester) async { + await _pumpApp(tester); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + + final gesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + }); + + testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { + await _pumpApp(tester); + + final gesture = await tester.startGesture(const Offset(195, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-clear')), findsOneWidget); + }); + testWidgets('should_restore_light_theme_after_leaving_feed_tab', ( tester, ) async {