diff --git a/lib/components/MusicScreen/alphabet_item_list.dart b/lib/components/MusicScreen/alphabet_item_list.dart new file mode 100644 index 000000000..ad8292efd --- /dev/null +++ b/lib/components/MusicScreen/alphabet_item_list.dart @@ -0,0 +1,71 @@ +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:flutter/material.dart'; + +class AlphabetList extends StatefulWidget { + final Function(String) callback; + + final String sortOrder; + + const AlphabetList({super.key, required this.callback, required this.sortOrder}); + + @override + State createState() => _AlphabetListState(); +} + +class _AlphabetListState extends State { + List alphabet = ['#'] + + List.generate(26, (int index) { + return String.fromCharCode('A'.codeUnitAt(0) + index); + }); + + + List get getAlphabet => alphabet; + + @override + void initState() { + orderTheList(alphabet); + super.initState(); + } + + + @override + void didUpdateWidget(AlphabetList oldWidget) { + orderTheList(alphabet); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + alphabet.length, + (x) => InkWell( + onTap: () { + widget.callback(alphabet[x]); + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + child: Text( + alphabet[x].toUpperCase(), + ), + ), + ), + )), + ), + ), + ); + } + + void orderTheList(List list) { + widget.sortOrder == "Ascending" + ? list.sort() + : list.sort((a, b) => b.compareTo(a)); + } +} diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index 6a6f02bf8..83356813f 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:finamp/components/MusicScreen/artist_item_list_tile.dart'; @@ -5,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:hive/hive.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../models/jellyfin_models.dart'; import '../../models/finamp_models.dart'; @@ -17,6 +19,7 @@ import '../first_page_progress_indicator.dart'; import '../new_page_progress_indicator.dart'; import '../error_snackbar.dart'; import 'album_item.dart'; +import 'alphabet_item_list.dart'; class MusicScreenTabView extends StatefulWidget { const MusicScreenTabView({ @@ -38,7 +41,7 @@ class MusicScreenTabView extends StatefulWidget { final SortBy? sortBy; final SortOrder? sortOrder; final BaseItemDto? view; - final String? albumArtist; + final String? albumArtist; @override State createState() => _MusicScreenTabViewState(); @@ -56,7 +59,7 @@ class _MusicScreenTabViewState extends State static const _pageSize = 100; final PagingController _pagingController = - PagingController(firstPageKey: 0); + PagingController(firstPageKey: 0); List? offlineSortedItems; @@ -68,10 +71,15 @@ class _MusicScreenTabViewState extends State SortBy? _oldSortBy; SortOrder? _oldSortOrder; BaseItemDto? _oldView; + ScrollController? controller; + String? letterToSearch; + String lastSortOrder = SortOrder.ascending.toString(); + Timer? timer; // This function just lets us easily set stuff to the getItems call we want. Future _getPage(int pageKey) async { try { + final sortOrder = widget.sortOrder?.toString() ?? SortOrder.ascending.toString(); final newItems = await _jellyfinApiHelper.getItems( // If no parent item is specified, we try the view given as an argument. // If the view argument is null, fall back to the user's current view. @@ -90,12 +98,11 @@ class _MusicScreenTabViewState extends State (widget.tabContentType == TabContentType.songs ? "Album,SortName" : widget.parentItem == null - ? "SortName" - : widget.parentItem?.type == "MusicArtist" - ? "ProductionYear,PremiereDate" - : "SortName"), - sortOrder: - widget.sortOrder?.toString() ?? SortOrder.ascending.toString(), + ? "SortName" + : widget.parentItem?.type == "MusicArtist" + ? "ProductionYear,PremiereDate" + : "SortName"), + sortOrder: sortOrder, searchTerm: widget.searchTerm?.trim(), // If this is the genres tab, tell getItems to get genres. isGenres: widget.tabContentType == TabContentType.genres, @@ -109,6 +116,17 @@ class _MusicScreenTabViewState extends State } else { _pagingController.appendPage(newItems, pageKey + newItems.length); } + if(letterToSearch != null) { + scrollToLetter(letterToSearch); + timer?.cancel(); + timer = Timer(const Duration(seconds: 2, milliseconds: 500), () { + scrollToNearbyLetter(); + }); + } + setState(() { + lastSortOrder = sortOrder; + }); + } catch (e) { errorSnackbar(e, context); } @@ -116,19 +134,99 @@ class _MusicScreenTabViewState extends State String _getParentType() => widget.parentItem?.type! ?? - _finampUserHelper.currentUser!.currentView!.type!; + _finampUserHelper.currentUser!.currentView!.type!; @override void initState() { _pagingController.addPageRequestListener((pageKey) { _getPage(pageKey); }); + lastSortOrder = widget.sortOrder?.toString() ?? SortOrder.ascending.toString(); + controller = ScrollController(); super.initState(); } + + @override + void didUpdateWidget(oldWidget) { + setState(() { + lastSortOrder = widget.sortOrder?.toString() ?? SortOrder.ascending.toString(); + }); + super.didUpdateWidget(oldWidget); + } + + // Scrolls the list to the first occurrence of the letter in the list + // If clicked in the # element, it goes to the first one ( pixels = 0 ) + void scrollToLetter(String? clickedLetter) async { + String? letter = clickedLetter ?? letterToSearch; + if (letter == null) return; + + letterToSearch = letter; + + if (letter == '#') { + double targetScroll = lastSortOrder == SortOrder.ascending.toString() + ? -(controller!.position.maxScrollExtent * 10) + : controller!.position.maxScrollExtent * 10; + + await controller?.animateTo(targetScroll, + duration: const Duration(milliseconds: 200), curve: Curves.ease); + } else { + final indexWhere = _pagingController.itemList!.indexWhere((element) { + final name = element.name!; + final firstLetter = name.startsWith(RegExp(r'^the', caseSensitive: false)) + ? name.split(RegExp(r'^the', caseSensitive: false))[1].trim()[0] + : name[0].toUpperCase(); + return firstLetter == letter; + }); + + if (indexWhere >= 0) { + final scrollTo = (indexWhere * 72).toDouble(); + await controller?.animateTo(scrollTo, + duration: const Duration(milliseconds: 200), curve: Curves.ease); + letterToSearch = null; + } else { + await controller?.animateTo(controller!.position.maxScrollExtent*100, + duration: const Duration(milliseconds: 200), curve: Curves.ease); + } + } + } + + void scrollToNearbyLetter(){ + if (letterToSearch != null) { + const standardAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + final closestLetterIndex = standardAlphabet.indexOf(letterToSearch!); + if (closestLetterIndex != -1) { + for (int offset = 0; offset <= standardAlphabet.length; offset++) { + for (final direction in [1, -1]) { + final nextIndex = closestLetterIndex + offset * direction; + if (nextIndex >= 0 && nextIndex < standardAlphabet.length) { + final nextLetter = standardAlphabet[nextIndex]; + final nextLetterIndex = + _pagingController.itemList!.indexWhere((element) { + final firstLetter = element.name![0].toUpperCase(); + return firstLetter == nextLetter; + }); + + if (nextLetterIndex >= 0) { + final scrollTo = (nextLetterIndex * 72).toDouble(); + controller?.animateTo(scrollTo, + duration: const Duration(milliseconds: 200), + curve: Curves.ease); + letterToSearch = null; + return; + } + } + } + } + } + } + } + + @override void dispose() { _pagingController.dispose(); + timer?.cancel(); super.dispose(); } @@ -180,19 +278,21 @@ class _MusicScreenTabViewState extends State // If we're on the songs tab, just get all of the downloaded items offlineSortedItems = downloadsHelper.downloadedItems .where((element) => - element.viewId == - _finampUserHelper.currentUser!.currentViewId) + element.viewId == + _finampUserHelper.currentUser!.currentViewId) .map((e) => e.song) .toList(); } else { String? albumArtist = widget.albumArtist; offlineSortedItems = downloadsHelper.downloadedParents .where((element) => - element.item.type == - _includeItemTypes(widget.tabContentType) && - element.viewId == - _finampUserHelper.currentUser!.currentViewId && - (albumArtist == null || element.item.albumArtist?.toLowerCase() == albumArtist.toLowerCase())) + element.item.type == + _includeItemTypes(widget.tabContentType) && + element.viewId == + _finampUserHelper.currentUser!.currentViewId && + (albumArtist == null || + element.item.albumArtist?.toLowerCase() == + albumArtist.toLowerCase())) .map((e) => e.item) .toList(); } @@ -201,24 +301,24 @@ class _MusicScreenTabViewState extends State offlineSortedItems = downloadsHelper.downloadedItems .where( (element) { - return _offlineSearch( - item: element.song, - searchTerm: widget.searchTerm!, - tabContentType: widget.tabContentType); - }, - ) + return _offlineSearch( + item: element.song, + searchTerm: widget.searchTerm!, + tabContentType: widget.tabContentType); + }, + ) .map((e) => e.song) .toList(); } else { offlineSortedItems = downloadsHelper.downloadedParents .where( (element) { - return _offlineSearch( - item: element.item, - searchTerm: widget.searchTerm!, - tabContentType: widget.tabContentType); - }, - ) + return _offlineSearch( + item: element.item, + searchTerm: widget.searchTerm!, + tabContentType: widget.tabContentType); + }, + ) .map((e) => e.item) .toList(); } @@ -275,8 +375,8 @@ class _MusicScreenTabViewState extends State return a.premiereDate!.compareTo(b.premiereDate!); } case SortBy.random: - // We subtract the result by one so that we can get -1 values - // (see comareTo documentation) + // We subtract the result by one so that we can get -1 values + // (see comareTo documentation) return Random().nextInt(2) - 1; default: throw UnimplementedError( @@ -293,57 +393,66 @@ class _MusicScreenTabViewState extends State } return Scrollbar( - child: box.get("FinampSettings")!.contentViewType == - ContentViewType.list - ? ListView.builder( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - itemCount: offlineSortedItems!.length, - key: UniqueKey(), - itemBuilder: (context, index) { - if (widget.tabContentType == TabContentType.songs) { - return SongListTile( - item: offlineSortedItems![index], - isSong: true, - ); - } else { - return AlbumItem( - album: offlineSortedItems![index], - parentType: _getParentType(), - ); - } - }, - ) - : GridView.builder( - itemCount: offlineSortedItems!.length, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: MediaQuery.of(context).size.width > - MediaQuery.of(context).size.height - ? box - .get("FinampSettings")! - .contentGridViewCrossAxisCountLandscape - : box - .get("FinampSettings")! - .contentGridViewCrossAxisCountPortrait, - ), - itemBuilder: (context, index) { - if (widget.tabContentType == TabContentType.songs) { - return SongListTile( - item: offlineSortedItems![index], - isSong: true, - ); - } else { - return AlbumItem( - album: offlineSortedItems![index], - parentType: _getParentType(), - isGrid: true, - gridAddSettingsListener: false, - ); - } - }, - )); + controller: controller, + child: Stack( + children: [ + box.get("FinampSettings")!.contentViewType == + ContentViewType.list + ? ListView.builder( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: offlineSortedItems!.length, + key: UniqueKey(), + controller: controller, + itemBuilder: (context, index) { + if (widget.tabContentType == TabContentType.songs) { + return SongListTile( + item: offlineSortedItems![index], + isSong: true, + ); + } else { + return AlbumItem( + album: offlineSortedItems![index], + parentType: _getParentType(), + ); + } + }, + ) + : GridView.builder( + itemCount: offlineSortedItems!.length, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + controller: controller, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: MediaQuery.of(context).size.width > + MediaQuery.of(context).size.height + ? box + .get("FinampSettings")! + .contentGridViewCrossAxisCountLandscape + : box + .get("FinampSettings")! + .contentGridViewCrossAxisCountPortrait, + ), + itemBuilder: (context, index) { + if (widget.tabContentType == TabContentType.songs) { + return SongListTile( + item: offlineSortedItems![index], + isSong: true, + ); + } else { + return AlbumItem( + album: offlineSortedItems![index], + parentType: _getParentType(), + isGrid: true, + gridAddSettingsListener: false, + ); + } + }, + ), + AlphabetList(callback: scrollToLetter, sortOrder:lastSortOrder), + ], + ), + ); } else { // If the searchTerm argument is different to lastSearch, the user has changed their search input. // This makes albumViewFuture search again so that results with the search are shown. @@ -367,78 +476,92 @@ class _MusicScreenTabViewState extends State // to run refresh() inside an async function onRefresh: () => Future.sync(() => _pagingController.refresh()), child: Scrollbar( - child: box.get("FinampSettings")!.contentViewType == + controller: controller, + child: Stack( + children: [ + box.get("FinampSettings")!.contentViewType == ContentViewType.list - ? PagedListView.separated( - pagingController: _pagingController, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - if (widget.tabContentType == TabContentType.songs) { - return SongListTile( - item: item, - isSong: true, - ); - } else if (widget.tabContentType == - TabContentType.artists) { - return ArtistListTile(item: item); - } else { - return AlbumItem( - album: item, - parentType: _getParentType(), - ); - } - }, - firstPageProgressIndicatorBuilder: (_) => - const FirstPageProgressIndicator(), - newPageProgressIndicatorBuilder: (_) => - const NewPageProgressIndicator(), - ), - separatorBuilder: (context, index) => SizedBox( - height: widget.tabContentType == - TabContentType.artists || - widget.tabContentType == TabContentType.genres - ? 16.0 - : 0.0, - ), - ) - : PagedGridView( - pagingController: _pagingController, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - if (widget.tabContentType == TabContentType.songs) { - return SongListTile( - item: item, - isSong: true, - ); - } else { - return AlbumItem( - album: item, - parentType: _getParentType(), - isGrid: true, - gridAddSettingsListener: false, - ); - } - }, - firstPageProgressIndicatorBuilder: (_) => - const FirstPageProgressIndicator(), - newPageProgressIndicatorBuilder: (_) => - const NewPageProgressIndicator(), - ), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: MediaQuery.of(context).size.width > - MediaQuery.of(context).size.height - ? box - .get("FinampSettings")! - .contentGridViewCrossAxisCountLandscape - : box - .get("FinampSettings")! - .contentGridViewCrossAxisCountPortrait, - ), + ? PagedListView.separated( + pagingController: _pagingController, + scrollController: controller, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + builderDelegate: + PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + if (widget.tabContentType == + TabContentType.songs) { + return SongListTile( + item: item, + isSong: true, + ); + } else if (widget.tabContentType == + TabContentType.artists) { + return ArtistListTile(item: item); + } else { + return AlbumItem( + album: item, + parentType: _getParentType(), + ); + } + }, + firstPageProgressIndicatorBuilder: (_) => + const FirstPageProgressIndicator(), + newPageProgressIndicatorBuilder: (_) => + const NewPageProgressIndicator(), + ), + separatorBuilder: (context, index) => SizedBox( + height: widget.tabContentType == + TabContentType.artists || + widget.tabContentType == + TabContentType.genres + ? 16.0 + : 0.0, + ), + ) + : PagedGridView( + pagingController: _pagingController, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + scrollController: controller, + builderDelegate: + PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + if (widget.tabContentType == + TabContentType.songs) { + return SongListTile( + item: item, + isSong: true, + ); + } else { + return AlbumItem( + album: item, + parentType: _getParentType(), + isGrid: true, + gridAddSettingsListener: false, + ); + } + }, + firstPageProgressIndicatorBuilder: (_) => + const FirstPageProgressIndicator(), + newPageProgressIndicatorBuilder: (_) => + const NewPageProgressIndicator(), + ), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: MediaQuery.of(context).size.width > + MediaQuery.of(context).size.height + ? box + .get("FinampSettings")! + .contentGridViewCrossAxisCountLandscape + : box + .get("FinampSettings")! + .contentGridViewCrossAxisCountPortrait, ), + ), + AlphabetList(callback: scrollToLetter, sortOrder:lastSortOrder), + ], + ), ), ); } @@ -466,8 +589,8 @@ String _includeItemTypes(TabContentType tabContentType) { bool _offlineSearch( {required BaseItemDto item, - required String searchTerm, - required TabContentType tabContentType}) { + required String searchTerm, + required TabContentType tabContentType}) { late bool containsName; // This horrible thing is for null safety diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index 5b89eed94..4647753bd 100644 --- a/lib/screens/music_screen.dart +++ b/lib/screens/music_screen.dart @@ -261,7 +261,10 @@ class _MusicScreenState extends State ), bottomNavigationBar: const NowPlayingBar(), drawer: const MusicScreenDrawer(), - floatingActionButton: getFloatingActionButton(), + floatingActionButton: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: getFloatingActionButton(), + ), body: TabBarView( controller: _tabController, children: tabs