fix(player): stabilize mobile content strip
This commit is contained in:
@@ -124,6 +124,7 @@ class MobileVideoControls extends StatefulWidget {
|
|||||||
class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTickerProviderStateMixin {
|
class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _stripAnim;
|
late final AnimationController _stripAnim;
|
||||||
bool _stripVisible = false;
|
bool _stripVisible = false;
|
||||||
|
late bool _lastControlsVisible;
|
||||||
|
|
||||||
/// Drag distance (in pixels) required to fully reveal the strip.
|
/// Drag distance (in pixels) required to fully reveal the strip.
|
||||||
static const _dragExtent = 150.0;
|
static const _dragExtent = 150.0;
|
||||||
@@ -136,6 +137,7 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
|||||||
super.initState();
|
super.initState();
|
||||||
_stripAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
|
_stripAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
|
||||||
_stripAnim.addListener(_onStripAnimChanged);
|
_stripAnim.addListener(_onStripAnimChanged);
|
||||||
|
_lastControlsVisible = widget.chromeController?.controlsVisible ?? true;
|
||||||
widget.chromeController?.addListener(_onChromeVisibilityChanged);
|
widget.chromeController?.addListener(_onChromeVisibilityChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +146,7 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
|||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.chromeController != widget.chromeController) {
|
if (oldWidget.chromeController != widget.chromeController) {
|
||||||
oldWidget.chromeController?.removeListener(_onChromeVisibilityChanged);
|
oldWidget.chromeController?.removeListener(_onChromeVisibilityChanged);
|
||||||
|
_lastControlsVisible = widget.chromeController?.controlsVisible ?? true;
|
||||||
widget.chromeController?.addListener(_onChromeVisibilityChanged);
|
widget.chromeController?.addListener(_onChromeVisibilityChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,12 +168,16 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onChromeVisibilityChanged() {
|
void _onChromeVisibilityChanged() {
|
||||||
if (widget.chromeController?.controlsVisible == false && _stripVisible) {
|
final controlsVisible = widget.chromeController?.controlsVisible ?? true;
|
||||||
|
final wasControlsVisible = _lastControlsVisible;
|
||||||
|
_lastControlsVisible = controlsVisible;
|
||||||
|
|
||||||
|
if (!controlsVisible && wasControlsVisible && _stripVisible) {
|
||||||
// Just notify parent that strip is no longer active — don't animate,
|
// Just notify parent that strip is no longer active — don't animate,
|
||||||
// let the overlay fade out with the strip still showing.
|
// let the overlay fade out with the strip still showing.
|
||||||
_stripVisible = false;
|
_stripVisible = false;
|
||||||
widget.onStripVisibilityChanged?.call(false);
|
widget.onStripVisibilityChanged?.call(false);
|
||||||
} else if (widget.chromeController?.controlsVisible == true && _stripAnim.value > 0) {
|
} else if (controlsVisible && !wasControlsVisible && _stripAnim.value > 0) {
|
||||||
// Reset strip when controls reappear so page 0 is shown.
|
// Reset strip when controls reappear so page 0 is shown.
|
||||||
_stripAnim.value = 0;
|
_stripAnim.value = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../../../services/download_storage_service.dart';
|
|||||||
import '../../../utils/formatters.dart';
|
import '../../../utils/formatters.dart';
|
||||||
import '../../../utils/player_utils.dart';
|
import '../../../utils/player_utils.dart';
|
||||||
import '../../../utils/provider_extensions.dart';
|
import '../../../utils/provider_extensions.dart';
|
||||||
|
import '../../../utils/scroll_utils.dart';
|
||||||
import '../../app_icon.dart';
|
import '../../app_icon.dart';
|
||||||
import '../../clickable_cursor.dart';
|
import '../../clickable_cursor.dart';
|
||||||
import '../../optimized_media_image.dart';
|
import '../../optimized_media_image.dart';
|
||||||
@@ -69,8 +70,11 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
late _StripTab _activeTab;
|
late _StripTab _activeTab;
|
||||||
final ScrollController _chapterScrollController = ScrollController();
|
final ScrollController _chapterScrollController = ScrollController();
|
||||||
final ScrollController _queueScrollController = ScrollController();
|
final ScrollController _queueScrollController = ScrollController();
|
||||||
bool _hasAutoScrolledChapters = false;
|
int? _lastAutoScrolledChapterIndex;
|
||||||
bool _hasAutoScrolledQueue = false;
|
int? _lastAutoScrolledQueueItemID;
|
||||||
|
int? _lastAutoScrolledQueueIndex;
|
||||||
|
final Map<int, GlobalKey> _chapterItemKeys = {};
|
||||||
|
final Map<int, GlobalKey> _queueItemKeys = {};
|
||||||
|
|
||||||
// Focus nodes for focus navigation mode
|
// Focus nodes for focus navigation mode
|
||||||
final List<FocusNode> _chapterFocusNodes = [];
|
final List<FocusNode> _chapterFocusNodes = [];
|
||||||
@@ -172,6 +176,18 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _selectTab(_StripTab tab) {
|
||||||
|
setState(() {
|
||||||
|
_activeTab = tab;
|
||||||
|
if (tab == _StripTab.chapters) {
|
||||||
|
_lastAutoScrolledChapterIndex = null;
|
||||||
|
} else {
|
||||||
|
_lastAutoScrolledQueueItemID = null;
|
||||||
|
_lastAutoScrolledQueueIndex = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
KeyEventResult _handleFocusItemKeyEvent(KeyEvent event, int index, int totalItems, _StripTab page) {
|
KeyEventResult _handleFocusItemKeyEvent(KeyEvent event, int index, int totalItems, _StripTab page) {
|
||||||
if (!event.isActionable) return KeyEventResult.ignored;
|
if (!event.isActionable) return KeyEventResult.ignored;
|
||||||
|
|
||||||
@@ -200,7 +216,10 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
if (key == LogicalKeyboardKey.arrowUp) {
|
if (key == LogicalKeyboardKey.arrowUp) {
|
||||||
if (page == _StripTab.queue && _hasChapters) {
|
if (page == _StripTab.queue && _hasChapters) {
|
||||||
// Switch to chapters page and focus current chapter
|
// Switch to chapters page and focus current chapter
|
||||||
setState(() => _activeTab = _StripTab.chapters);
|
setState(() {
|
||||||
|
_activeTab = _StripTab.chapters;
|
||||||
|
_lastAutoScrolledChapterIndex = null;
|
||||||
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && _chapterFocusNodes.isNotEmpty) {
|
if (mounted && _chapterFocusNodes.isNotEmpty) {
|
||||||
final idx = (_getCurrentChapterIndex() ?? 0).clamp(0, _chapterFocusNodes.length - 1);
|
final idx = (_getCurrentChapterIndex() ?? 0).clamp(0, _chapterFocusNodes.length - 1);
|
||||||
@@ -219,7 +238,11 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
if (key == LogicalKeyboardKey.arrowDown) {
|
if (key == LogicalKeyboardKey.arrowDown) {
|
||||||
if (page == _StripTab.chapters && _hasQueue) {
|
if (page == _StripTab.chapters && _hasQueue) {
|
||||||
// Switch to queue page and focus current queue item
|
// Switch to queue page and focus current queue item
|
||||||
setState(() => _activeTab = _StripTab.queue);
|
setState(() {
|
||||||
|
_activeTab = _StripTab.queue;
|
||||||
|
_lastAutoScrolledQueueItemID = null;
|
||||||
|
_lastAutoScrolledQueueIndex = null;
|
||||||
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && _queueFocusNodes.isNotEmpty) {
|
if (mounted && _queueFocusNodes.isNotEmpty) {
|
||||||
final idx = (_getCurrentQueueIndex() ?? 0).clamp(0, _queueFocusNodes.length - 1);
|
final idx = (_getCurrentQueueIndex() ?? 0).clamp(0, _queueFocusNodes.length - 1);
|
||||||
@@ -242,13 +265,47 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
|
|
||||||
double _itemWidth(bool isTablet) => isTablet ? 212.0 : 132.0; // thumb + 12 padding
|
double _itemWidth(bool isTablet) => isTablet ? 212.0 : 132.0; // thumb + 12 padding
|
||||||
|
|
||||||
void _autoScrollTo(ScrollController controller, int index, {bool force = false, bool isTablet = false}) {
|
GlobalKey _itemKeyFor(Map<int, GlobalKey> keys, int index) {
|
||||||
if (!controller.hasClients) return;
|
return keys.putIfAbsent(index, GlobalKey.new);
|
||||||
final itemWidth = _itemWidth(isTablet);
|
}
|
||||||
final target = (index * itemWidth - 60).clamp(0.0, controller.position.maxScrollExtent);
|
|
||||||
if (force || (target - controller.offset).abs() > itemWidth) {
|
void _trimItemKeys(Map<int, GlobalKey> keys, int count) {
|
||||||
controller.jumpTo(target);
|
keys.removeWhere((index, _) => index >= count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _autoScrollTo(
|
||||||
|
ScrollController controller,
|
||||||
|
Map<int, GlobalKey> keys,
|
||||||
|
int index, {
|
||||||
|
required bool isTablet,
|
||||||
|
required bool Function() isCurrent,
|
||||||
|
int attempt = 0,
|
||||||
|
}) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !isCurrent()) return;
|
||||||
|
|
||||||
|
if (controller.positions.length != 1 || !controller.position.hasContentDimensions) {
|
||||||
|
if (attempt < 3) {
|
||||||
|
_autoScrollTo(controller, keys, index, isTablet: isTablet, isCurrent: isCurrent, attempt: attempt + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollListToIndex(
|
||||||
|
controller,
|
||||||
|
index,
|
||||||
|
itemExtent: _itemWidth(isTablet),
|
||||||
|
leadingPadding: widget.useFocusNavigation ? 12 : 4,
|
||||||
|
animate: false,
|
||||||
|
);
|
||||||
|
scrollKeyedChildToHorizontalCenter(
|
||||||
|
controller,
|
||||||
|
_itemKeyFor(keys, index),
|
||||||
|
animate: false,
|
||||||
|
maxAttempts: 4,
|
||||||
|
isCurrent: isCurrent,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -302,7 +359,7 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
final isActive = _activeTab == tab;
|
final isActive = _activeTab == tab;
|
||||||
return ClickableCursor(
|
return ClickableCursor(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => setState(() => _activeTab = tab),
|
onTap: () => _selectTab(tab),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
children: [
|
children: [
|
||||||
@@ -333,12 +390,17 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
||||||
final currentChapterIndex = MediaChapter.indexAtPosition(currentPosition, widget.chapters);
|
final currentChapterIndex = MediaChapter.indexAtPosition(currentPosition, widget.chapters);
|
||||||
|
|
||||||
// Auto-scroll to current chapter on first build
|
_trimItemKeys(_chapterItemKeys, widget.chapters.length);
|
||||||
if (!_hasAutoScrolledChapters && currentChapterIndex != null) {
|
|
||||||
_hasAutoScrolledChapters = true;
|
if (currentChapterIndex != null && _lastAutoScrolledChapterIndex != currentChapterIndex) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
_lastAutoScrolledChapterIndex = currentChapterIndex;
|
||||||
_autoScrollTo(_chapterScrollController, currentChapterIndex, isTablet: isTablet);
|
_autoScrollTo(
|
||||||
});
|
_chapterScrollController,
|
||||||
|
_chapterItemKeys,
|
||||||
|
currentChapterIndex,
|
||||||
|
isTablet: isTablet,
|
||||||
|
isCurrent: () => _lastAutoScrolledChapterIndex == currentChapterIndex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.useFocusNavigation) {
|
if (widget.useFocusNavigation) {
|
||||||
@@ -361,7 +423,9 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
|
|
||||||
void onTap() => unawaited(_handleChapterTap(chapter.startTime));
|
void onTap() => unawaited(_handleChapterTap(chapter.startTime));
|
||||||
|
|
||||||
|
final itemKey = _itemKeyFor(_chapterItemKeys, index);
|
||||||
final item = _buildStripItem(
|
final item = _buildStripItem(
|
||||||
|
key: itemKey,
|
||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
isTablet: isTablet,
|
isTablet: isTablet,
|
||||||
thumbnail: chapter.thumb != null
|
thumbnail: chapter.thumb != null
|
||||||
@@ -415,13 +479,24 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
builder: (context, playbackState, _) {
|
builder: (context, playbackState, _) {
|
||||||
final items = playbackState.loadedItems;
|
final items = playbackState.loadedItems;
|
||||||
final currentItemID = playbackState.currentPlayQueueItemID;
|
final currentItemID = playbackState.currentPlayQueueItemID;
|
||||||
final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID);
|
final currentIndex = currentItemID == null
|
||||||
|
? -1
|
||||||
|
: items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID);
|
||||||
|
|
||||||
if (!_hasAutoScrolledQueue && currentIndex >= 0) {
|
_trimItemKeys(_queueItemKeys, items.length);
|
||||||
_hasAutoScrolledQueue = true;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
if (currentIndex >= 0 &&
|
||||||
_autoScrollTo(_queueScrollController, currentIndex, isTablet: isTablet);
|
(_lastAutoScrolledQueueItemID != currentItemID || _lastAutoScrolledQueueIndex != currentIndex)) {
|
||||||
});
|
_lastAutoScrolledQueueItemID = currentItemID;
|
||||||
|
_lastAutoScrolledQueueIndex = currentIndex;
|
||||||
|
_autoScrollTo(
|
||||||
|
_queueScrollController,
|
||||||
|
_queueItemKeys,
|
||||||
|
currentIndex,
|
||||||
|
isTablet: isTablet,
|
||||||
|
isCurrent: () =>
|
||||||
|
_lastAutoScrolledQueueItemID == currentItemID && _lastAutoScrolledQueueIndex == currentIndex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.useFocusNavigation) {
|
if (widget.useFocusNavigation) {
|
||||||
@@ -444,7 +519,9 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
|
|
||||||
void onTap() => widget.onQueueItemSelected?.call(item);
|
void onTap() => widget.onQueueItemSelected?.call(item);
|
||||||
|
|
||||||
|
final itemKey = _itemKeyFor(_queueItemKeys, index);
|
||||||
final stripItem = _buildStripItem(
|
final stripItem = _buildStripItem(
|
||||||
|
key: itemKey,
|
||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
isTablet: isTablet,
|
isTablet: isTablet,
|
||||||
thumbnail: item.thumbPath != null
|
thumbnail: item.thumbPath != null
|
||||||
@@ -501,6 +578,7 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStripItem({
|
Widget _buildStripItem({
|
||||||
|
Key? key,
|
||||||
required bool isCurrent,
|
required bool isCurrent,
|
||||||
required Widget? thumbnail,
|
required Widget? thumbnail,
|
||||||
required String title,
|
required String title,
|
||||||
@@ -518,6 +596,7 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
key: key,
|
||||||
width: itemWidth,
|
width: itemWidth,
|
||||||
margin: .symmetric(horizontal: 6, vertical: verticalMargin),
|
margin: .symmetric(horizontal: 6, vertical: verticalMargin),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
Reference in New Issue
Block a user