fix(recommend): 修正进度拖动与底栏联动
This commit is contained in:
1 parent
afe7a49ef0
commit
2765343340
9 files changed
+79
-107
No files matched your search
@@ -1183,6 +1183,7 @@ $ flutter test
|
||||
- 按最新反馈移除整行榜单与热度胶囊,信息顺序收敛为标题、剧名、标签、简介、观看全集。
|
||||
- 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。
|
||||
- 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。
|
||||
- 拖动期间隐藏操作栏、文字及根底栏,顶部导航常驻;松手后持续清屏直到点击画面。
|
||||
- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;松手恢复拖动前浮层状态。
|
||||
- 拖动仅控制视频浮层内容,顶部导航与根底栏保持常驻。
|
||||
- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。
|
||||
- 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。
|
||||
- 观看全集按钮相对进度条上移 8,间距调整为 20;`flutter analyze` 无问题。
|
||||
@@ -23,7 +23,6 @@ class HomeLogic extends ChangeNotifier {
|
||||
bool drawerOpen = false;
|
||||
bool detailOpen = false;
|
||||
bool videoImmersive = false;
|
||||
bool scrubHidden = false;
|
||||
bool _dramaNotifyScheduled = false;
|
||||
bool _disposed = false;
|
||||
|
||||
@@ -145,12 +144,6 @@ class HomeLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
|
||||
void onScrubVisibilityChanged(bool value) {
|
||||
if (scrubHidden == value) return;
|
||||
scrubHidden = value;
|
||||
notify();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -239,15 +239,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
backgroundColor: AppColors.transparent,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: _body(context),
|
||||
bottomNavigationBar: logic.videoImmersive
|
||||
? null
|
||||
: Visibility(
|
||||
visible: !logic.scrubHidden,
|
||||
maintainSize: true,
|
||||
maintainAnimation: true,
|
||||
maintainState: true,
|
||||
child: _nav(context),
|
||||
),
|
||||
bottomNavigationBar: logic.videoImmersive ? null : _nav(context),
|
||||
),
|
||||
);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
@@ -296,7 +288,6 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
active: logic.tab == 1,
|
||||
onSearch: () => _openSearch(context),
|
||||
onOpenFilter: () => _pushPage(context, const FilterPage()),
|
||||
onScrubVisibilityChanged: logic.onScrubVisibilityChanged,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -914,15 +914,19 @@ class PlayerLogic extends ChangeNotifier {
|
||||
void beginScrub(VideoPlayerPort player) {
|
||||
if (session.active != player) return;
|
||||
scrubbingPlayer = player;
|
||||
session.setOverlayOpen(true);
|
||||
}
|
||||
|
||||
void cancelScrub() {
|
||||
if (scrubbingPlayer == null) return;
|
||||
scrubbingPlayer = null;
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
|
||||
Future<void> endScrub(
|
||||
BuildContext context,
|
||||
VideoPlayerPort player,
|
||||
Duration position,
|
||||
) => seekTo(context, player, position);
|
||||
|
||||
Future<void> seekTo(
|
||||
BuildContext context,
|
||||
VideoPlayerPort player,
|
||||
|
||||
@@ -70,7 +70,6 @@ class PlayerPage extends StatefulWidget {
|
||||
this.showHomeFeedHeader = true,
|
||||
this.onDramaChanged,
|
||||
this.onImmersiveChanged,
|
||||
this.onScrubVisibilityChanged,
|
||||
this.onOpenFullPlayer,
|
||||
this.onRefreshFeed,
|
||||
this.onLoadMore,
|
||||
@@ -97,7 +96,6 @@ class PlayerPage extends StatefulWidget {
|
||||
final bool showHomeFeedHeader;
|
||||
final ValueChanged<Drama>? onDramaChanged;
|
||||
final ValueChanged<bool>? onImmersiveChanged;
|
||||
final ValueChanged<bool>? onScrubVisibilityChanged;
|
||||
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
|
||||
final Future<bool> Function()? onRefreshFeed;
|
||||
final Future<void> Function()? onLoadMore;
|
||||
@@ -273,7 +271,6 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
void _hideChrome() {
|
||||
if (!_chromeVisible) return;
|
||||
setState(() => _chromeVisible = false);
|
||||
widget.onScrubVisibilityChanged?.call(true);
|
||||
}
|
||||
|
||||
/// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。
|
||||
@@ -288,7 +285,6 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
_playbackSpeed = _boostPlaybackSpeed;
|
||||
_chromeVisible = false;
|
||||
});
|
||||
widget.onScrubVisibilityChanged?.call(true);
|
||||
unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed));
|
||||
}
|
||||
|
||||
@@ -304,14 +300,12 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
_playbackSpeed = speed;
|
||||
_chromeVisible = chrome;
|
||||
});
|
||||
widget.onScrubVisibilityChanged?.call(!chrome);
|
||||
unawaited(logic.setPlaybackSpeed(speed));
|
||||
}
|
||||
|
||||
void _setScrubbing(bool value) {
|
||||
if (_scrubbing == value) return;
|
||||
setState(() => _scrubbing = value);
|
||||
widget.onScrubVisibilityChanged?.call(value);
|
||||
}
|
||||
|
||||
void _finishFeedScrub(bool wasVisible) {
|
||||
@@ -319,7 +313,6 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
_scrubbing = false;
|
||||
_chromeVisible = wasVisible;
|
||||
});
|
||||
widget.onScrubVisibilityChanged?.call(!wasVisible);
|
||||
}
|
||||
|
||||
void _cancelFeedScrub() {
|
||||
@@ -333,7 +326,6 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
_scrubbing = false;
|
||||
_chromeVisible = true;
|
||||
});
|
||||
widget.onScrubVisibilityChanged?.call(false);
|
||||
}
|
||||
|
||||
void _handleFeedSurfaceTap() {
|
||||
@@ -676,7 +668,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onScrubVisibilityChanged: _setScrubbing,
|
||||
cancelScrubRequest: widget.scrubCancelRequest,
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
? (position) => logic.endScrub(context, videoPort!, position)
|
||||
: null,
|
||||
thumbnailService: _thumbnailService,
|
||||
spriteAxisService: _spriteAxisService,
|
||||
|
||||
@@ -22,6 +22,7 @@ const double _progressHitHeight = 28;
|
||||
const double _pageHorizontal = 14;
|
||||
const double _railRight = 8;
|
||||
const double _railGap = 12;
|
||||
const double _bottomInfoInset = 20;
|
||||
const double _railBottomOffset = 60;
|
||||
const List<Shadow> _boostTextShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
|
||||
@@ -141,7 +142,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _railRight,
|
||||
bottom: 12,
|
||||
bottom: _bottomInfoInset,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
@@ -184,6 +185,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
void _endScrub() {
|
||||
final wasVisible = _wasVisibleBeforeScrub;
|
||||
if (_scrubbing) setState(() => _scrubbing = false);
|
||||
widget.onScrubVisibilityChanged?.call(false);
|
||||
widget.onScrubEnd?.call(wasVisible);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
double? _preview;
|
||||
Timer? _bufferTimer;
|
||||
bool _buffering = false;
|
||||
bool _dragging = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -61,6 +62,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
|
||||
_dragging = false;
|
||||
_preview = null;
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
@@ -68,6 +70,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
if (widget.locked) {
|
||||
_dragging = false;
|
||||
_preview = null;
|
||||
} else {
|
||||
_syncBufferState(notify: false);
|
||||
@@ -88,7 +91,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final source = widget.controller;
|
||||
final scrubbing = _preview != null;
|
||||
final scrubbing = _dragging;
|
||||
final loading = !widget.locked && !scrubbing && _buffering;
|
||||
final track = widget.locked
|
||||
? _inactiveTrack()
|
||||
@@ -115,49 +118,41 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
!loading &&
|
||||
(source?.value.isInitialized ?? false);
|
||||
final video = source?.value;
|
||||
final previewPosition = _preview == null || video == null
|
||||
final previewPosition = !_dragging || _preview == null || video == null
|
||||
? null
|
||||
: seekPositionForFraction(_preview!, video.duration);
|
||||
final showPreview = previewPosition != null;
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-progress'),
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: interactive
|
||||
? (details) {
|
||||
widget.onScrubStart?.call();
|
||||
_update(details.localPosition.dx / width);
|
||||
}
|
||||
: null,
|
||||
onHorizontalDragUpdate: interactive
|
||||
? (details) => _update(details.localPosition.dx / width)
|
||||
: null,
|
||||
onHorizontalDragEnd: interactive
|
||||
? (_) {
|
||||
widget.onScrubEnd?.call();
|
||||
_commit(_preview);
|
||||
}
|
||||
: null,
|
||||
onHorizontalDragCancel: interactive
|
||||
? () {
|
||||
widget.onScrubEnd?.call();
|
||||
_cancelPreview();
|
||||
}
|
||||
: null,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
track,
|
||||
if (showPreview)
|
||||
Positioned(
|
||||
left: _timeBubbleLeft(width, _preview!),
|
||||
bottom: _lineCenterFromBottom + 16,
|
||||
child: _ScrubTimeBubble(
|
||||
key: const Key('scrub-preview-time-bubble'),
|
||||
position: previewPosition,
|
||||
duration: video!.duration,
|
||||
onPointerUp: (_) => _finishScrub(),
|
||||
onPointerCancel: (_) => _finishScrub(),
|
||||
child: GestureDetector(
|
||||
key: const Key('home-feed-progress'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: interactive
|
||||
? (details) => _startScrub(details.localPosition.dx / width)
|
||||
: null,
|
||||
onHorizontalDragUpdate: interactive
|
||||
? (details) => _update(details.localPosition.dx / width)
|
||||
: null,
|
||||
onHorizontalDragEnd: interactive ? (_) => _finishScrub() : null,
|
||||
onHorizontalDragCancel: interactive ? _finishScrub : null,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
track,
|
||||
if (showPreview)
|
||||
Positioned(
|
||||
left: _timeBubbleLeft(width, _preview!),
|
||||
bottom: _lineCenterFromBottom + 16,
|
||||
child: _ScrubTimeBubble(
|
||||
key: const Key('scrub-preview-time-bubble'),
|
||||
position: previewPosition,
|
||||
duration: video!.duration,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -259,23 +254,32 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
);
|
||||
}
|
||||
|
||||
void _startScrub(double fraction) {
|
||||
if (_dragging) return;
|
||||
setState(() {
|
||||
_dragging = true;
|
||||
_preview = fraction.clamp(0.0, 1.0).toDouble();
|
||||
});
|
||||
widget.onScrubStart?.call();
|
||||
}
|
||||
|
||||
void _update(double fraction) {
|
||||
if (!_dragging) return;
|
||||
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
|
||||
}
|
||||
|
||||
void _commit(double? fraction) {
|
||||
void _finishScrub() {
|
||||
if (!_dragging) return;
|
||||
final fraction = _preview;
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_preview = null;
|
||||
});
|
||||
widget.onScrubEnd?.call();
|
||||
if (widget.locked || fraction == null) return;
|
||||
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
|
||||
if (total > 0) {
|
||||
widget.onSeek?.call(
|
||||
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
||||
);
|
||||
}
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
void _cancelPreview() {
|
||||
setState(() => _preview = null);
|
||||
if (total <= 0) return;
|
||||
widget.onSeek?.call(Duration(milliseconds: (total * fraction).round()));
|
||||
}
|
||||
|
||||
Widget _track(double fraction, double width) {
|
||||
|
||||
@@ -28,14 +28,12 @@ class RecommendPage extends StatefulWidget {
|
||||
required this.active,
|
||||
required this.onSearch,
|
||||
required this.onOpenFilter,
|
||||
required this.onScrubVisibilityChanged,
|
||||
});
|
||||
|
||||
final HomeFeedController feedController;
|
||||
final bool active;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onOpenFilter;
|
||||
final ValueChanged<bool> onScrubVisibilityChanged;
|
||||
|
||||
@override
|
||||
State<RecommendPage> createState() => _RecommendPageState();
|
||||
@@ -43,7 +41,6 @@ class RecommendPage extends StatefulWidget {
|
||||
|
||||
class _RecommendPageState extends State<RecommendPage> {
|
||||
RecommendInnerTab _tab = RecommendInnerTab.feed;
|
||||
bool _scrubbing = false;
|
||||
int _scrubCancelRequest = 0;
|
||||
|
||||
@override
|
||||
@@ -159,38 +156,22 @@ class _RecommendPageState extends State<RecommendPage> {
|
||||
_openDrama(drama, episodeId: episode.id),
|
||||
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;
|
||||
_scrubCancelRequest++;
|
||||
});
|
||||
if (wasScrubbing) widget.onScrubVisibilityChanged(false);
|
||||
}
|
||||
|
||||
void _handleSearch() {
|
||||
if (_scrubbing) {
|
||||
setState(() {
|
||||
_scrubbing = false;
|
||||
_scrubCancelRequest++;
|
||||
});
|
||||
widget.onScrubVisibilityChanged(false);
|
||||
}
|
||||
setState(() => _scrubCancelRequest++);
|
||||
widget.onSearch();
|
||||
}
|
||||
|
||||
void _setScrubbing(bool value) {
|
||||
if (_scrubbing == value) return;
|
||||
setState(() => _scrubbing = value);
|
||||
widget.onScrubVisibilityChanged(value);
|
||||
}
|
||||
|
||||
Widget _withTopNavSpace(Widget child) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
|
||||
@@ -102,15 +102,19 @@ void main() {
|
||||
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));
|
||||
final navVisibility = find.ancestor(
|
||||
of: find.byKey(const Key('home-tabs-bar')),
|
||||
matching: find.byType(Visibility),
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-tabs-bar'))).height,
|
||||
AppDimens.navHeight,
|
||||
);
|
||||
expect(tester.widget<Visibility>(navVisibility.first).visible, isFalse);
|
||||
await scrub.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget);
|
||||
expect(tester.widget<Visibility>(navVisibility.first).visible, isTrue);
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-tabs-bar'))).height,
|
||||
AppDimens.navHeight,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Reference in new issue
Block a user