fix(plex): repair alpha jump bar

This commit is contained in:
edde746
2026-05-08 05:36:39 +02:00
parent 92014404f5
commit f07feaaa52
7 changed files with 263 additions and 83 deletions
+8 -1
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../focus/key_event_utils.dart';
import '../../media/library_first_character.dart';
import 'alpha_jump_helper.dart';
@@ -143,6 +144,13 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final selectResult = handleOneShotSelect(event, () {
if (_highlightedIndex < _displayed.length) {
_jumpToLetter(_displayed[_highlightedIndex]);
}
});
if (selectResult != KeyEventResult.ignored) return selectResult;
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
@@ -171,7 +179,6 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
widget.onBack?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@@ -0,0 +1,51 @@
/// Scroll geometry used by the library alpha jump bar.
///
/// The browse tab can render either a fixed-grid layout or a one-column list.
/// This small value object keeps the shared index/offset math out of the
/// widget state so both modes use the same clamping behaviour.
class LibraryAlphaScrollMetrics {
final int columnCount;
final double rowHeight;
final double itemWidth;
final double itemHeight;
const LibraryAlphaScrollMetrics({
required this.columnCount,
required this.rowHeight,
required this.itemWidth,
required this.itemHeight,
});
static const empty = LibraryAlphaScrollMetrics(columnCount: 1, rowHeight: 0, itemWidth: 0, itemHeight: 0);
bool get isUsable => columnCount > 0 && rowHeight > 0;
LibraryAlphaScrollMetrics copyWith({int? columnCount, double? rowHeight, double? itemWidth, double? itemHeight}) {
return LibraryAlphaScrollMetrics(
columnCount: columnCount ?? this.columnCount,
rowHeight: rowHeight ?? this.rowHeight,
itemWidth: itemWidth ?? this.itemWidth,
itemHeight: itemHeight ?? this.itemHeight,
);
}
int itemIndexFromScrollOffset(double offset, {required double contentStartOffset, required int totalSize}) {
if (!isUsable) return 0;
final contentOffset = (offset - contentStartOffset).clamp(0.0, double.infinity);
final row = (contentOffset / rowHeight).floor();
final maxIndex = totalSize > 0 ? totalSize - 1 : 0;
return (row * columnCount).clamp(0, maxIndex);
}
double scrollOffsetForItemIndex(int index, {required double contentStartOffset}) {
if (!isUsable) return contentStartOffset;
final targetRow = index ~/ columnCount;
return contentStartOffset + targetRow * rowHeight;
}
int visibleItemCount(double viewportHeight) {
if (!isUsable || !viewportHeight.isFinite) return 0;
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
return visibleRows * columnCount;
}
}
@@ -27,11 +27,13 @@ import '../alpha_jump_bar.dart';
import '../alpha_jump_helper.dart';
import '../alpha_scroll_handle.dart';
import '../library_alpha_bar_strategy.dart';
import '../library_alpha_scroll_metrics.dart';
import '../library_filter_sort_loader.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/focusable_filter_chip.dart';
import '../../../widgets/loading_indicator_box.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/media_card_list_layout.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../folder_tree_view.dart';
@@ -195,9 +197,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// doesn't need to call back into a Plex client for value listings.
Map<String, List<MediaFilterValue>> _jellyfinFilterValues = const {};
final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(0);
int _currentColumnCount = 1;
double _lastCrossAxisExtent = 0;
LibraryAlphaScrollMetrics _scrollMetrics = LibraryAlphaScrollMetrics.empty;
double _effectiveTopPadding = _gridTopPadding;
final GlobalKey _firstListItemKey = GlobalKey(debugLabel: 'first_library_list_item');
double? _measuredListRowHeight;
int? _listMetricsDensity;
bool? _listMetricsUsesWideRatio;
final FocusNode _alphaJumpBarFocusNode = FocusNode(debugLabel: 'alpha_jump_bar');
// When the user taps a letter, pin the highlight so scroll-based recalculation
// doesn't immediately override it (e.g. when the letter has fewer items than a full row).
@@ -360,6 +365,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_isJumpScrolling = false;
_jumpScrollGeneration++;
_currentFirstVisibleIndex.value = 0;
_measuredListRowHeight = null;
// The browse tab state is kept alive across libraries, so ensure this
// tab's scroll resets to 0 (other tabs keep their own positions). Defer
@@ -462,6 +468,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_selectedGrouping = _getDefaultGrouping();
_firstCharacters = [];
_alphaHelper = AlphaJumpHelper(const []);
_scrollMetrics = LibraryAlphaScrollMetrics.empty;
_measuredListRowHeight = null;
});
}
@@ -799,10 +807,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// FocusNode detached), so we target the last-column item in the first
/// visible row — the grid cell closest to the alpha bar.
void _navigateToGridNearScroll() {
if (totalSize == 0 || _currentColumnCount < 1) return;
final columnCount = _scrollMetrics.columnCount;
if (totalSize == 0 || columnCount < 1) return;
final row = _currentFirstVisibleIndex.value ~/ _currentColumnCount;
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, totalSize - 1);
final row = _currentFirstVisibleIndex.value ~/ columnCount;
var targetIndex = ((row + 1) * columnCount - 1).clamp(0, totalSize - 1);
// Find nearest loaded item — skeleton cards have no FocusNode
if (!loadedItems.containsKey(targetIndex)) {
@@ -923,7 +932,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
prefetchAhead(range.firstIndex, range.visibleCount, pageSize: _fetchSize);
}
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
if (!_shouldShowAlphaJumpBar || !_scrollMetrics.isUsable) return;
// During a jump animation, skip alpha bar processing to avoid flashing.
if (_isJumpScrolling) return;
@@ -957,7 +966,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Use the last item in the first visible row so the highlighted letter
// updates as soon as items with a new letter appear in that row.
final maxIndex = totalSize > 0 ? totalSize - 1 : 0;
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex);
final lastInRow = (firstInRow + _scrollMetrics.columnCount - 1).clamp(0, maxIndex);
if (lastInRow != _currentFirstVisibleIndex.value) {
_currentFirstVisibleIndex.value = lastInRow;
}
@@ -967,20 +976,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Chips bar is the first sliver (height = _chipsBarHeight) followed by the
/// grid's own top padding before the first row.
int _itemIndexFromScrollOffset(double offset) {
if (_lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return 0;
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return 0;
// Items start at `_chipsBarHeight + _effectiveTopPadding` in scroll coords.
final contentOffset = (offset - _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
final row = (contentOffset / rowHeight).floor();
final maxIndex = totalSize > 0 ? totalSize - 1 : 0;
return (row * _currentColumnCount).clamp(0, maxIndex);
return _scrollMetrics.itemIndexFromScrollOffset(
offset,
contentStartOffset: _contentStartScrollOffset,
totalSize: totalSize,
);
}
double get _contentStartScrollOffset => _chipsBarHeight + _effectiveTopPadding;
/// Handle a tap on the letter at [targetIndex] in the alpha bar. The
/// active [LibraryAlphaBarStrategy] owns the per-backend behaviour and
/// invokes one of the two callbacks — Plex scrolls the grid to the
@@ -996,7 +1000,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
);
}
/// Scroll the grid so the item at [targetIndex] becomes the first visible
/// Scroll the current layout so the item at [targetIndex] becomes the first visible
/// row. Used by [PlexAlphaBarStrategy] via [_jumpToIndex].
void _scrollGridToIndex(int targetIndex) {
_jumpScrollGeneration++;
@@ -1025,21 +1029,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_loadItems();
}
/// Scroll the grid so that [index] is visible just below the chips bar
/// Scroll the current layout so that [index] is visible just below the chips bar
void _scrollToItemIndex(int index) {
final pos = _innerPosition;
if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || pos == null) {
if (!_scrollMetrics.isUsable || pos == null) {
_isJumpScrolling = false;
return;
}
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
final targetRow = index ~/ _currentColumnCount;
// Position the target row at the top of the viewport. Chips and the grid's
// top padding both precede the items in scroll coordinates.
final offset = _chipsBarHeight + _effectiveTopPadding + targetRow * rowHeight;
final offset = _scrollMetrics.scrollOffsetForItemIndex(index, contentStartOffset: _contentStartScrollOffset);
final gen = _jumpScrollGeneration;
final maxExtent = pos.maxScrollExtent;
@@ -1201,21 +1201,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
/// Returns the first-visible index and visible count from the scroll
/// position + grid metrics, or null if the viewport isn't measured yet.
/// position + layout metrics, or null if the viewport isn't measured yet.
({int firstIndex, int visibleCount})? _computeVisibleRange() {
final pos = _innerPosition;
if (_currentColumnCount < 1 || pos == null || _lastCrossAxisExtent <= 0) return null;
if (!_scrollMetrics.isUsable || pos == null) return null;
final offset = pos.pixels;
final viewportHeight = pos.viewportDimension;
if (!viewportHeight.isFinite) return null;
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return null;
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
return (firstIndex: _itemIndexFromScrollOffset(offset), visibleCount: visibleRows * _currentColumnCount);
final visibleCount = _scrollMetrics.visibleItemCount(viewportHeight);
if (visibleCount <= 0) return null;
return (firstIndex: _itemIndexFromScrollOffset(offset), visibleCount: visibleCount);
}
/// Compute initial fetch size based on viewport dimensions.
@@ -1241,21 +1237,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Prefetch images for items near the viewport to reduce pop-in.
void _prefetchImages(int startIndex, List<MediaItem> items) {
final pos = _innerPosition;
if (pos == null || _lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return;
if (pos == null || !_scrollMetrics.isUsable) return;
final offset = pos.pixels;
final viewportHeight = pos.viewportDimension;
if (!viewportHeight.isFinite) return;
final firstVisible = _itemIndexFromScrollOffset(offset);
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return;
final itemWidth = _scrollMetrics.itemWidth;
final itemHeight = _scrollMetrics.itemHeight;
if (itemWidth <= 0 || itemHeight <= 0) return;
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
final visibleEnd = firstVisible + visibleRows * _currentColumnCount;
final visibleEnd = firstVisible + _scrollMetrics.visibleItemCount(viewportHeight);
// Prefetch 2 rows beyond visible area
final prefetchEnd = visibleEnd + 2 * _currentColumnCount;
final prefetchEnd = visibleEnd + 2 * _scrollMetrics.columnCount;
final client = getMediaClientForLibrary();
final devicePixelRatio = MediaImageHelper.effectiveDevicePixelRatio(context);
@@ -1414,6 +1408,42 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Width of the alpha jump bar widget
static const double _alphaJumpBarWidth = 20.0;
void _setListScrollMetrics({required int density, required bool usesWideAspectRatio}) {
if (_listMetricsDensity != density || _listMetricsUsesWideRatio != usesWideAspectRatio) {
_measuredListRowHeight = null;
_listMetricsDensity = density;
_listMetricsUsesWideRatio = usesWideAspectRatio;
}
final itemWidth = MediaCardListLayout.posterWidth(density: density, usesWideAspectRatio: usesWideAspectRatio);
final itemHeight = MediaCardListLayout.posterHeight(density: density, usesWideAspectRatio: usesWideAspectRatio);
final rowHeight =
_measuredListRowHeight ??
MediaCardListLayout.estimatedRowHeight(density: density, usesWideAspectRatio: usesWideAspectRatio);
_scrollMetrics = LibraryAlphaScrollMetrics(
columnCount: 1,
rowHeight: rowHeight,
itemWidth: itemWidth,
itemHeight: itemHeight,
);
}
Widget _buildMeasuredFirstListItem(Widget child) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureFirstListRowHeight());
return KeyedSubtree(key: _firstListItemKey, child: child);
}
void _measureFirstListRowHeight() {
if (!mounted) return;
if (SettingsService.instanceOrNull?.read(SettingsService.viewMode) != ViewMode.list) return;
final height = (_firstListItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height;
if (height == null || height <= 0) return;
if ((_measuredListRowHeight ?? 0) == height) return;
_measuredListRowHeight = height;
_scrollMetrics = _scrollMetrics.copyWith(rowHeight: height);
_updateVisibleIndex();
}
/// Builds either a sliver list or sliver grid based on the view mode
Widget _buildItemsSliver(BuildContext context) {
final svc = SettingsService.instanceOrNull!;
@@ -1426,26 +1456,36 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_effectiveTopPadding = topPadding;
final rightPadding = _shouldShowAlphaJumpBar && !isPhone ? _alphaJumpBarWidth : 8.0;
final useWideRatio = _selectedGrouping == 'episodes' && episodePosterMode == EpisodePosterMode.episodeThumbnail;
if (viewMode == ViewMode.list) {
// In list view, all items are in a single column (first column)
return SliverPadding(
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverList.builder(
itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem(
index,
isFirstRow: index == 0,
isFirstColumn: true, // List view = single column
disableScale: true,
columnCount: 1,
itemCount: itemCount,
),
sliver: SliverLayoutBuilder(
builder: (context, _) {
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio);
return SliverList.builder(
itemCount: itemCount,
itemBuilder: (context, index) {
final child = _buildMediaCardItem(
index,
isFirstRow: index == 0,
isFirstColumn: true, // List view = single column
isLastColumn: true,
disableScale: true,
columnCount: 1,
itemCount: itemCount,
);
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
},
);
},
),
);
} else {
// In grid view, calculate columns and pass to item builder
// Use 16:9 aspect ratio when browsing episodes with episode thumbnail mode
final useWideRatio = _selectedGrouping == 'episodes' && episodePosterMode == EpisodePosterMode.episodeThumbnail;
final baseMaxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity);
final effectiveMaxExtent = useWideRatio ? baseMaxExtent * 1.8 : baseMaxExtent;
final hasAlphaBarReservation = rightPadding > 8.0;
@@ -1459,8 +1499,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final baselineWidth = constraints.crossAxisExtent + (rightPadding - 8.0);
final columnCount = GridSizeCalculator.getColumnCount(baselineWidth, effectiveMaxExtent);
// Cache grid metrics for alpha jump bar scroll calculations
_lastCrossAxisExtent = constraints.crossAxisExtent;
_currentColumnCount = columnCount;
final itemWidth = constraints.crossAxisExtent / columnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
_scrollMetrics = LibraryAlphaScrollMetrics(
columnCount: columnCount,
rowHeight: itemHeight + GridLayoutConstants.mainAxisSpacing,
itemWidth: itemWidth,
itemHeight: itemHeight,
);
return SliverGrid.builder(
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
+9 -25
View File
@@ -24,6 +24,7 @@ import '../theme/mono_tokens.dart';
import '../i18n/strings.g.dart';
import 'media_context_menu.dart';
import 'media_progress_bar.dart';
import 'media_card_list_layout.dart';
import 'optimized_media_image.dart';
class MediaCard extends StatefulWidget {
@@ -341,34 +342,17 @@ class _MediaCardList extends StatelessWidget {
this.showServerName = false,
});
double _basePosterWidth() {
return 70 + LibraryDensity.factor(density) * 50; // 70120
bool _usesWideAspectRatio() {
if (item is! MediaItem) return false;
final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
return (item as MediaItem).usesWideAspectRatio(mode);
}
double _posterWidth(BuildContext context) {
final base = _basePosterWidth();
// For episodes with thumbnail mode, use wider width to maintain reasonable thumbnail size
if (item is MediaItem) {
final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
if ((item as MediaItem).usesWideAspectRatio(mode)) {
return base * 1.6; // Wider for 16:9 thumbnails
}
}
return base;
}
double _posterWidth(BuildContext context) =>
MediaCardListLayout.posterWidth(density: density, usesWideAspectRatio: _usesWideAspectRatio());
double _posterHeight(BuildContext context) {
final base = _basePosterWidth();
// For episodes with thumbnail mode, use 16:9 aspect ratio
if (item is MediaItem) {
final mode = SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
if ((item as MediaItem).usesWideAspectRatio(mode)) {
// 16:9: height = width * 9/16 = base * 1.6 * 9/16 = base * 0.9
return base * 0.9;
}
}
return base * 1.5; // Default 2:3 aspect ratio
}
double _posterHeight(BuildContext context) =>
MediaCardListLayout.posterHeight(density: density, usesWideAspectRatio: _usesWideAspectRatio());
double get _titleFontSize => 13 + LibraryDensity.factor(density) * 3; // 1316
+25
View File
@@ -0,0 +1,25 @@
import '../services/settings_service.dart' show LibraryDensity;
/// Shared sizing math for media cards rendered in list mode.
class MediaCardListLayout {
static const double padding = 8.0;
static double basePosterWidth(int density) {
return 70 + LibraryDensity.factor(density) * 50;
}
static double posterWidth({required int density, required bool usesWideAspectRatio}) {
final base = basePosterWidth(density);
return usesWideAspectRatio ? base * 1.6 : base;
}
static double posterHeight({required int density, required bool usesWideAspectRatio}) {
final base = basePosterWidth(density);
return usesWideAspectRatio ? base * 0.9 : base * 1.5;
}
static double estimatedRowHeight({required int density, required bool usesWideAspectRatio}) {
final poster = posterHeight(density: density, usesWideAspectRatio: usesWideAspectRatio);
return poster + padding * 2;
}
}
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/library_first_character.dart';
import 'package:plezy/screens/libraries/alpha_jump_bar.dart';
void main() {
testWidgets('Enter jumps to the highlighted letter', (tester) async {
final focusNode = FocusNode(debugLabel: 'test_alpha_jump_bar');
addTearDown(focusNode.dispose);
int? jumpedTo;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 300,
child: AlphaJumpBar(
firstCharacters: const [
LibraryFirstCharacter(key: 'A', title: 'A', size: 3),
LibraryFirstCharacter(key: 'B', title: 'B', size: 4),
LibraryFirstCharacter(key: 'C', title: 'C', size: 2),
],
currentLetter: 'B',
focusNode: focusNode,
onJump: (index) => jumpedTo = index,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(jumpedTo, 3);
});
}
@@ -0,0 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/libraries/library_alpha_scroll_metrics.dart';
void main() {
test('maps scroll offsets to item indices by row and column count', () {
const metrics = LibraryAlphaScrollMetrics(columnCount: 4, rowHeight: 100, itemWidth: 150, itemHeight: 90);
expect(metrics.itemIndexFromScrollOffset(0, contentStartOffset: 50, totalSize: 40), 0);
expect(metrics.itemIndexFromScrollOffset(149, contentStartOffset: 50, totalSize: 40), 0);
expect(metrics.itemIndexFromScrollOffset(150, contentStartOffset: 50, totalSize: 40), 4);
expect(metrics.itemIndexFromScrollOffset(1050, contentStartOffset: 50, totalSize: 40), 39);
});
test('maps item indices back to scroll offsets', () {
const metrics = LibraryAlphaScrollMetrics(columnCount: 4, rowHeight: 100, itemWidth: 150, itemHeight: 90);
expect(metrics.scrollOffsetForItemIndex(0, contentStartOffset: 50), 50);
expect(metrics.scrollOffsetForItemIndex(3, contentStartOffset: 50), 50);
expect(metrics.scrollOffsetForItemIndex(4, contentStartOffset: 50), 150);
expect(metrics.scrollOffsetForItemIndex(9, contentStartOffset: 50), 250);
});
test('counts visible items using one extra trailing row', () {
const metrics = LibraryAlphaScrollMetrics(columnCount: 3, rowHeight: 100, itemWidth: 150, itemHeight: 90);
expect(metrics.visibleItemCount(250), 12);
});
}