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