merge: 修正进度拖动与底栏联动

This commit is contained in:
Zhou Weidong committed 2026-10-22 20:15:14 +08:00
commit 3690fb1bc4
9 files changed
+79 -107

No files matched your search

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