fix(recommend): 稳定进度拖动与清屏状态

This commit is contained in:
Zhou Weidong committed 2026-10-22 20:01:54 +08:00
1 parent 76c4f1d411
commit 30b55afdd4
9 files changed
+339 -96

No files matched your search

+2 -1
View File
@@ -1183,4 +1183,5 @@ $ flutter test
- 按最新反馈移除整行榜单与热度胶囊,信息顺序收敛为标题、剧名、标签、简介、观看全集。 - 按最新反馈移除整行榜单与热度胶囊,信息顺序收敛为标题、剧名、标签、简介、观看全集。
- 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。 - 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。
- 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。 - 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。
- 拖动期间隐藏顶部导航、操作栏、文字及根底栏,松手恢复;按当前流程未执行 analyze/test。 - 拖动期间隐藏操作栏、文字及根底栏,顶部导航常驻;松手后持续清屏直到点击画面。
- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;按当前流程未执行 analyze/test。
+7
View File
@@ -23,6 +23,7 @@ class HomeLogic extends ChangeNotifier {
bool drawerOpen = false; bool drawerOpen = false;
bool detailOpen = false; bool detailOpen = false;
bool videoImmersive = false; bool videoImmersive = false;
bool scrubHidden = false;
bool _dramaNotifyScheduled = false; bool _dramaNotifyScheduled = false;
bool _disposed = false; bool _disposed = false;
@@ -144,6 +145,12 @@ class HomeLogic extends ChangeNotifier {
notify(); notify();
} }
void onScrubVisibilityChanged(bool value) {
if (scrubHidden == value) return;
scrubHidden = value;
notify();
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
+10 -2
View File
@@ -239,7 +239,15 @@ class _HomeTabsState extends State<_HomeTabs> {
backgroundColor: AppColors.transparent, backgroundColor: AppColors.transparent,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: _body(context), body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context), bottomNavigationBar: logic.videoImmersive
? null
: Visibility(
visible: !logic.scrubHidden,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: _nav(context),
),
), ),
); );
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
@@ -288,7 +296,7 @@ class _HomeTabsState extends State<_HomeTabs> {
active: logic.tab == 1, active: logic.tab == 1,
onSearch: () => _openSearch(context), onSearch: () => _openSearch(context),
onOpenFilter: () => _pushPage(context, const FilterPage()), onOpenFilter: () => _pushPage(context, const FilterPage()),
onScrubVisibilityChanged: logic.onImmersiveChanged, onScrubVisibilityChanged: logic.onScrubVisibilityChanged,
); );
} }
+56 -8
View File
@@ -59,6 +59,7 @@ class PlayerPage extends StatefulWidget {
this.active = true, this.active = true,
this.resumeRequest = 0, this.resumeRequest = 0,
this.backRequest = 0, this.backRequest = 0,
this.scrubCancelRequest = 0,
this.onDiscover, this.onDiscover,
this.onMy, this.onMy,
this.onSearch, this.onSearch,
@@ -85,6 +86,7 @@ class PlayerPage extends StatefulWidget {
final bool active; final bool active;
final int resumeRequest; final int resumeRequest;
final int backRequest; final int backRequest;
final int scrubCancelRequest;
final VoidCallback? onDiscover; final VoidCallback? onDiscover;
final VoidCallback? onMy; final VoidCallback? onMy;
final VoidCallback? onSearch; final VoidCallback? onSearch;
@@ -117,6 +119,7 @@ class _PlayerPageState extends State<PlayerPage>
double _playbackSpeed = 1.0; double _playbackSpeed = 1.0;
bool _boosting = false; bool _boosting = false;
bool _scrubbing = false; bool _scrubbing = false;
bool _clearScreenFromScrub = false;
double? _speedBeforeBoost; double? _speedBeforeBoost;
bool? _chromeBeforeBoost; bool? _chromeBeforeBoost;
@@ -135,6 +138,9 @@ class _PlayerPageState extends State<PlayerPage>
downloads: widget.downloads, downloads: widget.downloads,
); );
logic.attach(widget, isMounted: () => mounted); logic.attach(widget, isMounted: () => mounted);
if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) {
_cancelFeedScrub();
}
_thumbnailService = PlaybackThumbnailService( _thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource, sourceResolver: _resolveThumbnailSource,
); );
@@ -266,7 +272,9 @@ class _PlayerPageState extends State<PlayerPage>
} }
void _hideChrome() { void _hideChrome() {
if (!_chromeVisible) return;
setState(() => _chromeVisible = false); setState(() => _chromeVisible = false);
widget.onScrubVisibilityChanged?.call(true);
} }
/// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。 /// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。
@@ -281,6 +289,7 @@ class _PlayerPageState extends State<PlayerPage>
_playbackSpeed = _boostPlaybackSpeed; _playbackSpeed = _boostPlaybackSpeed;
_chromeVisible = false; _chromeVisible = false;
}); });
widget.onScrubVisibilityChanged?.call(true);
unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed)); unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed));
} }
@@ -296,6 +305,7 @@ class _PlayerPageState extends State<PlayerPage>
_playbackSpeed = speed; _playbackSpeed = speed;
_chromeVisible = chrome; _chromeVisible = chrome;
}); });
widget.onScrubVisibilityChanged?.call(!chrome);
unawaited(logic.setPlaybackSpeed(speed)); unawaited(logic.setPlaybackSpeed(speed));
} }
@@ -305,6 +315,39 @@ class _PlayerPageState extends State<PlayerPage>
widget.onScrubVisibilityChanged?.call(value); widget.onScrubVisibilityChanged?.call(value);
} }
void _finishFeedScrub() {
setState(() {
_scrubbing = false;
_chromeVisible = false;
_clearScreenFromScrub = true;
});
}
void _cancelFeedScrub() {
logic.cancelScrub();
_scrubbing = false;
_chromeVisible = true;
_clearScreenFromScrub = false;
}
void _restoreFeedChrome() {
setState(() {
_scrubbing = false;
_chromeVisible = true;
_clearScreenFromScrub = false;
});
widget.onScrubVisibilityChanged?.call(false);
}
void _handleFeedSurfaceTap() {
if (_scrubbing) return;
if (!_chromeVisible) {
_restoreFeedChrome();
return;
}
logic.session.setUserWantsPlay(!logic.session.userWantsPlay);
}
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
setState(() { setState(() {
@@ -374,6 +417,7 @@ class _PlayerPageState extends State<PlayerPage>
if (widget.homeMode && if (widget.homeMode &&
widget.showHomeFeedHeader && widget.showHomeFeedHeader &&
!_scrubbing && !_scrubbing &&
_chromeVisible &&
!_boosting && !_boosting &&
!logic.pip && !logic.pip &&
!logic.pipPreparing) !logic.pipPreparing)
@@ -479,16 +523,14 @@ class _PlayerPageState extends State<PlayerPage>
controller: videoPort?.controller, controller: videoPort?.controller,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater, theater: theater,
onTap: () => onTap: () => _handleFeedSurfaceTap(),
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
), ),
if (widget.homeMode && _chromeVisible && !logic.chromeHidden) if (widget.homeMode && _chromeVisible && !logic.chromeHidden)
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
key: const Key('home-feed-video-toggle'), key: const Key('home-feed-video-toggle'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => onTap: _handleFeedSurfaceTap,
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
), ),
), ),
// 票据三态之一:起播前正在向后端要签名地址 // 票据三态之一:起播前正在向后端要签名地址
@@ -595,8 +637,7 @@ class _PlayerPageState extends State<PlayerPage>
boosting: _boosting, boosting: _boosting,
onBoostStart: _startBoost, onBoostStart: _startBoost,
onBoostEnd: _endBoost, onBoostEnd: _endBoost,
onTogglePlay: () => onTogglePlay: _handleFeedSurfaceTap,
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
liked: interactions.isEpisodeLiked(episode.id), liked: interactions.isEpisodeLiked(episode.id),
likeCount: likeCount:
interactions.episodeLikeCount(episode.id) ?? episode.likeCount, interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
@@ -634,7 +675,9 @@ class _PlayerPageState extends State<PlayerPage>
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onMore: _openCurrentMore, onMore: _openCurrentMore,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onScrubEnd: _finishFeedScrub,
onScrubVisibilityChanged: _setScrubbing, onScrubVisibilityChanged: _setScrubbing,
cancelScrubRequest: widget.scrubCancelRequest,
onSeek: ready onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position) ? (position) => logic.seekTo(context, videoPort!, position)
: null, : null,
@@ -706,18 +749,23 @@ class _PlayerPageState extends State<PlayerPage>
? null ? null
: () => _openUnlockSheet(context, index, episode), : () => _openUnlockSheet(context, index, episode),
), ),
if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing) if (widget.homeMode &&
!_chromeVisible &&
!_boosting &&
!_scrubbing &&
!_clearScreenFromScrub)
Positioned( Positioned(
right: 12, right: 12,
bottom: 56, bottom: 56,
child: ExitClearScreenButton( child: ExitClearScreenButton(
key: const Key('home-feed-exit-clear-screen'), key: const Key('home-feed-exit-clear-screen'),
tooltip: l10n.exitClearScreen, tooltip: l10n.exitClearScreen,
onPressed: () => setState(() => _chromeVisible = true), onPressed: _restoreFeedChrome,
), ),
), ),
if (widget.homeMode && if (widget.homeMode &&
!_scrubbing && !_scrubbing &&
_chromeVisible &&
!logic.chromeHidden && !logic.chromeHidden &&
selected && selected &&
!logic.session.userWantsPlay) !logic.session.userWantsPlay)
+36 -12
View File
@@ -13,13 +13,12 @@ import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'home_feed_recommend_panel.dart'; import 'home_feed_recommend_panel.dart';
import 'loading_progress_track.dart'; import 'loading_progress_track.dart';
import 'scrub_preview_card.dart';
import 'seek_progress_bar.dart'; import 'seek_progress_bar.dart';
part 'home_feed_overlay_parts.dart'; part 'home_feed_overlay_parts.dart';
const double _progressHeight = 2; const double _progressHeight = 2;
const double _progressHitHeight = 24; const double _progressHitHeight = 28;
const double _pageHorizontal = 14; const double _pageHorizontal = 14;
const double _railRight = 8; const double _railRight = 8;
const double _railGap = 12; const double _railGap = 12;
@@ -60,6 +59,7 @@ class HomeFeedOverlay extends StatefulWidget {
this.onScrubEnd, this.onScrubEnd,
this.onScrubVisibilityChanged, this.onScrubVisibilityChanged,
this.onSeek, this.onSeek,
this.cancelScrubRequest = 0,
this.thumbnailService, this.thumbnailService,
this.spriteAxisService, this.spriteAxisService,
}); });
@@ -92,6 +92,7 @@ class HomeFeedOverlay extends StatefulWidget {
final VoidCallback? onScrubEnd; final VoidCallback? onScrubEnd;
final ValueChanged<bool>? onScrubVisibilityChanged; final ValueChanged<bool>? onScrubVisibilityChanged;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
final int cancelScrubRequest;
final PlaybackThumbnailService? thumbnailService; final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService; final SpriteThumbnailAxisService? spriteAxisService;
@@ -101,6 +102,16 @@ class HomeFeedOverlay extends StatefulWidget {
class _HomeFeedOverlayState extends State<HomeFeedOverlay> { class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
bool _scrubbing = false; bool _scrubbing = false;
bool _clearAfterScrub = false;
@override
void didUpdateWidget(covariant HomeFeedOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
_scrubbing = false;
_clearAfterScrub = false;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -116,7 +127,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
child: GestureDetector( child: GestureDetector(
key: const Key('home-feed-gesture'), key: const Key('home-feed-gesture'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: widget.onTogglePlay, onTap: _handleSurfaceTap,
onLongPressStart: (details) => onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth), _handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => widget.onBoostEnd(), onLongPressEnd: (_) => widget.onBoostEnd(),
@@ -127,7 +138,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
), ),
if (widget.boosting) if (widget.boosting)
Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) Positioned(left: 0, right: 0, bottom: 20, child: _boostHint())
else if (widget.visible && !_scrubbing) else if (widget.visible && !_scrubbing && !_clearAfterScrub)
Positioned( Positioned(
left: _pageHorizontal, left: _pageHorizontal,
right: _railRight, right: _railRight,
@@ -153,14 +164,10 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
locked: widget.locked, locked: widget.locked,
height: _progressHeight, height: _progressHeight,
hitHeight: _progressHitHeight, hitHeight: _progressHitHeight,
cancelScrubRequest: widget.cancelScrubRequest,
onScrubStart: _startScrub, onScrubStart: _startScrub,
onScrubEnd: _endScrub, onScrubEnd: _endScrub,
onSeek: widget.onSeek, onSeek: widget.onSeek,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
), ),
), ),
], ],
@@ -169,17 +176,34 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
} }
void _startScrub() { void _startScrub() {
if (!_scrubbing) setState(() => _scrubbing = true); if (!_scrubbing || _clearAfterScrub) {
setState(() {
_scrubbing = true;
_clearAfterScrub = false;
});
}
widget.onScrubVisibilityChanged?.call(true); widget.onScrubVisibilityChanged?.call(true);
widget.onScrubStart?.call(); widget.onScrubStart?.call();
} }
void _endScrub() { void _endScrub() {
if (_scrubbing) setState(() => _scrubbing = false); if (_scrubbing || !_clearAfterScrub) {
widget.onScrubVisibilityChanged?.call(false); setState(() {
_scrubbing = false;
_clearAfterScrub = true;
});
}
widget.onScrubEnd?.call(); widget.onScrubEnd?.call();
} }
void _handleSurfaceTap() {
if (_clearAfterScrub) {
setState(() => _clearAfterScrub = false);
widget.onScrubVisibilityChanged?.call(false);
}
widget.onTogglePlay();
}
void _handleLongPressStart(LongPressStartDetails details, double width) { void _handleLongPressStart(LongPressStartDetails details, double width) {
final third = width / 3; final third = width / 3;
final dx = details.localPosition.dx; final dx = details.localPosition.dx;
@@ -6,6 +6,9 @@ const double _thumbSize = 6;
const double _thumbActiveSize = 14; const double _thumbActiveSize = 14;
const double _trackActiveHeight = 6; const double _trackActiveHeight = 6;
const double _lineCenterFromBottom = 8; const double _lineCenterFromBottom = 8;
const Duration _trackResizeDuration = Duration(milliseconds: 120);
const double _timeBubbleWidth = 96;
const double _timeBubbleMargin = 12;
const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 /// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
@@ -15,28 +18,20 @@ class _FeedProgressBar extends StatefulWidget {
required this.locked, required this.locked,
required this.height, required this.height,
required this.hitHeight, required this.hitHeight,
this.cancelScrubRequest = 0,
this.onScrubStart, this.onScrubStart,
this.onScrubEnd, this.onScrubEnd,
this.onSeek, this.onSeek,
this.thumbnailService,
this.spriteAxisService,
this.thumbnailsUrl,
required this.episodeId,
required this.posterUrl,
}); });
final ValueListenable<VideoPlayerValue>? controller; final ValueListenable<VideoPlayerValue>? controller;
final bool locked; final bool locked;
final double height; final double height;
final double hitHeight; final double hitHeight;
final int cancelScrubRequest;
final VoidCallback? onScrubStart; final VoidCallback? onScrubStart;
final VoidCallback? onScrubEnd; final VoidCallback? onScrubEnd;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
final String? thumbnailsUrl;
final String episodeId;
final String posterUrl;
@override @override
State<_FeedProgressBar> createState() => _FeedProgressBarState(); State<_FeedProgressBar> createState() => _FeedProgressBarState();
@@ -63,15 +58,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
_bufferTimer?.cancel(); _bufferTimer?.cancel();
_bufferTimer = null; _bufferTimer = null;
_buffering = false; _buffering = false;
widget.thumbnailService?.cancelPending();
_attachSource(); _attachSource();
} }
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
_preview = null;
}
if (oldWidget.locked != widget.locked) { if (oldWidget.locked != widget.locked) {
_bufferTimer?.cancel(); _bufferTimer?.cancel();
_bufferTimer = null; _bufferTimer = null;
_buffering = false; _buffering = false;
if (widget.locked) { if (widget.locked) {
widget.thumbnailService?.cancelPending();
_preview = null; _preview = null;
} else { } else {
_syncBufferState(notify: false); _syncBufferState(notify: false);
@@ -83,7 +79,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
void dispose() { void dispose() {
_detachSource(widget.controller); _detachSource(widget.controller);
_bufferTimer?.cancel(); _bufferTimer?.cancel();
if (_preview != null) widget.thumbnailService?.cancelPending();
super.dispose(); super.dispose();
} }
@@ -123,10 +118,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
final previewPosition = _preview == null || video == null final previewPosition = _preview == null || video == null
? null ? null
: seekPositionForFraction(_preview!, video.duration); : seekPositionForFraction(_preview!, video.duration);
final showPreview = final showPreview = previewPosition != null;
previewPosition != null &&
widget.thumbnailService != null &&
widget.episodeId.isNotEmpty;
return GestureDetector( return GestureDetector(
key: const Key('home-feed-progress'), key: const Key('home-feed-progress'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@@ -157,17 +149,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
track, track,
if (showPreview) if (showPreview)
Positioned( Positioned(
left: scrubPreviewLeft( left: _timeBubbleLeft(width, _preview!),
trackWidth: width,
fraction: _preview!,
),
bottom: widget.hitHeight + 8, bottom: widget.hitHeight + 8,
child: ScrubPreviewCard( child: _ScrubTimeBubble(
service: widget.thumbnailService!, key: const Key('scrub-preview-time-bubble'),
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.thumbnailsUrl,
episodeId: widget.episodeId,
posterUrl: widget.posterUrl,
position: previewPosition, position: previewPosition,
duration: video!.duration, duration: video!.duration,
), ),
@@ -286,12 +271,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()), Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
); );
} }
widget.thumbnailService?.cancelPending();
setState(() => _preview = null); setState(() => _preview = null);
} }
void _cancelPreview() { void _cancelPreview() {
widget.thumbnailService?.cancelPending();
setState(() => _preview = null); setState(() => _preview = null);
} }
@@ -299,11 +282,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
final dragging = _preview != null; final dragging = _preview != null;
final trackHeight = dragging ? _trackActiveHeight : widget.height; final trackHeight = dragging ? _trackActiveHeight : widget.height;
final size = dragging ? _thumbActiveSize : _thumbSize; final size = dragging ? _thumbActiveSize : _thumbSize;
final left = (width * fraction - size / 2) final thumbSlotLeft = (width * fraction - _thumbActiveSize / 2)
.clamp(0.0, width - size) .clamp(0.0, width - _thumbActiveSize)
.toDouble(); .toDouble();
final lineBottom = _lineCenterFromBottom - trackHeight / 2; final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2;
final thumbBottom = _lineCenterFromBottom - size / 2; final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2;
return SizedBox( return SizedBox(
height: widget.hitHeight, height: widget.hitHeight,
child: Stack( child: Stack(
@@ -312,32 +295,53 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: lineBottom, bottom: trackSlotBottom,
child: Container( child: SizedBox(
key: const Key('home-feed-progress-track'), height: _trackActiveHeight,
height: trackHeight, child: Stack(
color: AppColors.border, children: [
Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
key: const Key('home-feed-progress-track'),
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: width,
height: trackHeight,
color: AppColors.border,
),
),
Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: width * fraction,
height: trackHeight,
color: AppColors.blue,
),
),
],
),
), ),
), ),
Positioned( Positioned(
left: 0, left: thumbSlotLeft,
bottom: lineBottom, bottom: thumbSlotBottom,
child: Container( child: SizedBox.square(
width: width * fraction, dimension: _thumbActiveSize,
height: trackHeight, child: Center(
color: AppColors.blue, child: AnimatedContainer(
), key: const Key('home-feed-progress-thumb'),
), duration: _trackResizeDuration,
Positioned( curve: Curves.easeOut,
left: left, width: size,
bottom: thumbBottom, height: size,
child: Container( decoration: const BoxDecoration(
key: const Key('home-feed-progress-thumb'), color: AppColors.onPrimary,
width: size, shape: BoxShape.circle,
height: size, ),
decoration: const BoxDecoration( ),
color: AppColors.onPrimary,
shape: BoxShape.circle,
), ),
), ),
), ),
@@ -345,4 +349,56 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
), ),
); );
} }
double _timeBubbleLeft(double width, double fraction) {
final maxLeft = width - _timeBubbleWidth - _timeBubbleMargin;
if (maxLeft < _timeBubbleMargin) {
return ((width - _timeBubbleWidth) / 2)
.clamp(0.0, width - _timeBubbleWidth)
.toDouble();
}
return (width * fraction - _timeBubbleWidth / 2)
.clamp(_timeBubbleMargin, maxLeft)
.toDouble();
}
}
class _ScrubTimeBubble extends StatelessWidget {
const _ScrubTimeBubble({
super.key,
required this.position,
required this.duration,
});
final Duration position;
final Duration duration;
@override
Widget build(BuildContext context) {
return Container(
width: _timeBubbleWidth,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: AppColors.overlayMedium,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${_formatTime(position)} / ${_formatTime(duration)}',
key: const Key('scrub-preview-time'),
textAlign: TextAlign.center,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
String _formatTime(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
} }
+54 -12
View File
@@ -44,6 +44,7 @@ class RecommendPage extends StatefulWidget {
class _RecommendPageState extends State<RecommendPage> { class _RecommendPageState extends State<RecommendPage> {
RecommendInnerTab _tab = RecommendInnerTab.feed; RecommendInnerTab _tab = RecommendInnerTab.feed;
bool _scrubbing = false; bool _scrubbing = false;
int _scrubCancelRequest = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -80,21 +81,40 @@ class _RecommendPageState extends State<RecommendPage> {
), ),
), ),
), ),
if (!_scrubbing) Positioned(
Positioned( top: 0,
top: 0, left: 0,
left: 0, right: 0,
right: 0, child: IgnorePointer(
child: SafeArea( child: SizedBox(
bottom: false, height: 72,
child: RecommendTopNav( child: DecoratedBox(
selected: _tab, key: const Key('recommend-top-gradient'),
onVideo: _tab == RecommendInnerTab.feed, decoration: const BoxDecoration(
onSelected: (tab) => setState(() => _tab = tab), gradient: LinearGradient(
onSearch: widget.onSearch, begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.mediaBlack, AppColors.transparent],
),
),
), ),
), ),
), ),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: RecommendTopNav(
selected: _tab,
onVideo: _tab == RecommendInnerTab.feed,
onSelected: _selectTab,
onSearch: _handleSearch,
),
),
),
], ],
), ),
); );
@@ -141,9 +161,31 @@ class _RecommendPageState extends State<RecommendPage> {
onRefreshFeed: widget.feedController.refreshCurrent, onRefreshFeed: widget.feedController.refreshCurrent,
onLoadMore: widget.feedController.loadMore, onLoadMore: widget.feedController.loadMore,
onScrubVisibilityChanged: _setScrubbing, onScrubVisibilityChanged: _setScrubbing,
scrubCancelRequest: _scrubCancelRequest,
); );
} }
void _selectTab(RecommendInnerTab tab) {
final wasScrubbing = _scrubbing;
setState(() {
_tab = tab;
if (wasScrubbing) _scrubCancelRequest++;
_scrubbing = false;
});
if (wasScrubbing) widget.onScrubVisibilityChanged(false);
}
void _handleSearch() {
if (_scrubbing) {
setState(() {
_scrubbing = false;
_scrubCancelRequest++;
});
widget.onScrubVisibilityChanged(false);
}
widget.onSearch();
}
void _setScrubbing(bool value) { void _setScrubbing(bool value) {
if (_scrubbing == value) return; if (_scrubbing == value) return;
setState(() => _scrubbing = value); setState(() => _scrubbing = value);
+46 -5
View File
@@ -23,7 +23,6 @@ import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart';
import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
@@ -681,8 +680,12 @@ void main() {
); );
expect( expect(
tester.getSize(find.byKey(const Key('home-feed-progress'))).height, tester.getSize(find.byKey(const Key('home-feed-progress'))).height,
greaterThanOrEqualTo(24), greaterThanOrEqualTo(28),
); );
final idleTrackCenter = tester
.getRect(find.byKey(const Key('home-feed-progress-track')))
.center
.dy;
expect( expect(
tester tester
.getRect(find.byKey(const Key('home-feed-progress-thumb'))) .getRect(find.byKey(const Key('home-feed-progress-thumb')))
@@ -705,7 +708,33 @@ void main() {
expect(scrubVisibility.last, isTrue); expect(scrubVisibility.last, isTrue);
expect(find.byKey(const Key('home-feed-title')), findsNothing); expect(find.byKey(const Key('home-feed-title')), findsNothing);
expect(find.byKey(const Key('rail-save')), findsNothing); expect(find.byKey(const Key('rail-save')), findsNothing);
expect(find.byType(ScrubPreviewCard), findsOneWidget); expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget);
final bubbleRect = tester.getRect(
find.byKey(const Key('scrub-preview-time-bubble')),
);
expect(bubbleRect.left, greaterThanOrEqualTo(12));
expect(
bubbleRect.right,
lessThanOrEqualTo(
tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12,
),
);
await gesture.moveBy(const Offset(-600, 0));
await tester.pump();
expect(
tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).left,
closeTo(12, 0.1),
);
await gesture.moveBy(const Offset(1200, 0));
await tester.pump();
expect(
tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).right,
closeTo(
tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12,
0.1,
),
);
await tester.pump(const Duration(milliseconds: 130));
expect( expect(
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
const Size(14, 14), const Size(14, 14),
@@ -714,6 +743,13 @@ void main() {
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
6, 6,
); );
expect(
tester
.getRect(find.byKey(const Key('home-feed-progress-track')))
.center
.dy,
closeTo(idleTrackCenter, 0.1),
);
expect( expect(
tester tester
.getRect(find.byKey(const Key('home-feed-progress-thumb'))) .getRect(find.byKey(const Key('home-feed-progress-thumb')))
@@ -729,8 +765,8 @@ void main() {
); );
await gesture.up(); await gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(scrubVisibility.last, isFalse); expect(scrubVisibility.last, isTrue);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(find.byKey(const Key('home-feed-title')), findsNothing);
expect(seeked, isNotNull); expect(seeked, isNotNull);
expect(seeked!.inSeconds, greaterThan(0)); expect(seeked!.inSeconds, greaterThan(0));
expect( expect(
@@ -742,6 +778,11 @@ void main() {
2, 2,
); );
await tester.tap(find.byKey(const Key('home-feed-gesture')));
await tester.pump();
expect(scrubVisibility.last, isFalse);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pump(); await tester.pump();
expect(opened, 1); expect(opened, 1);
@@ -35,6 +35,7 @@ void main() {
expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget);
expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget);
expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget);
expect(find.byKey(const Key('recommend-top-gradient')), findsOneWidget);
final recommendText = tester.widget<Text>( final recommendText = tester.widget<Text>(
find.descendant( find.descendant(
of: find.byKey(const Key('recommend-tab-recommend')), of: find.byKey(const Key('recommend-tab-recommend')),
@@ -93,6 +94,21 @@ void main() {
expect(find.byType(PageView), findsOneWidget); expect(find.byType(PageView), findsOneWidget);
expect(find.byKey(const Key('recommend-section-hits')), findsNothing); expect(find.byKey(const Key('recommend-section-hits')), findsNothing);
expect(find.byType(AppBottomNav), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget);
final progress = find.byKey(const Key('home-feed-progress'));
final progressCenterY = tester.getCenter(progress).dy;
final scrub = await tester.startGesture(tester.getCenter(progress));
await scrub.moveBy(const Offset(40, 0));
await tester.pump(const Duration(milliseconds: 130));
expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget);
expect(tester.getCenter(progress).dy, closeTo(progressCenterY, 0.1));
await scrub.up();
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget);
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget);
}); });
testWidgets('should_render_playlist_sections_and_horizontal_cards', ( testWidgets('should_render_playlist_sections_and_horizontal_cards', (