Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jump to Letter when scrolling, closes #125 #510

Merged
merged 10 commits into from
Oct 17, 2023
71 changes: 71 additions & 0 deletions lib/components/MusicScreen/alphabet_item_list.dart
Original file line number Diff line number Diff line change
@@ -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<AlphabetList> createState() => _AlphabetListState();
}

class _AlphabetListState extends State<AlphabetList> {
List<String> alphabet = ['#'] +
List.generate(26, (int index) {
return String.fromCharCode('A'.codeUnitAt(0) + index);
});


List<String> 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<String> list) {
widget.sortOrder == "Ascending"
? list.sort()
: list.sort((a, b) => b.compareTo(a));
}
}
429 changes: 276 additions & 153 deletions lib/components/MusicScreen/music_screen_tab_view.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:math';

import 'package:finamp/components/MusicScreen/artist_item_list_tile.dart';
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<MusicScreenTabView> createState() => _MusicScreenTabViewState();
@@ -56,7 +59,7 @@ class _MusicScreenTabViewState extends State<MusicScreenTabView>
static const _pageSize = 100;

final PagingController<int, BaseItemDto> _pagingController =
PagingController(firstPageKey: 0);
PagingController(firstPageKey: 0);

List<BaseItemDto>? offlineSortedItems;

@@ -68,10 +71,15 @@ class _MusicScreenTabViewState extends State<MusicScreenTabView>
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<void> _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<MusicScreenTabView>
(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,26 +116,117 @@ class _MusicScreenTabViewState extends State<MusicScreenTabView>
} 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);
}
}

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<MusicScreenTabView>
// 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<MusicScreenTabView>
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<MusicScreenTabView>
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<MusicScreenTabView>
}

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<MusicScreenTabView>
// 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<int, BaseItemDto>.separated(
pagingController: _pagingController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
builderDelegate: PagedChildBuilderDelegate<BaseItemDto>(
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<BaseItemDto>(
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<int, BaseItemDto>.separated(
pagingController: _pagingController,
scrollController: controller,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
builderDelegate:
PagedChildBuilderDelegate<BaseItemDto>(
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<BaseItemDto>(
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
5 changes: 4 additions & 1 deletion lib/screens/music_screen.dart
Original file line number Diff line number Diff line change
@@ -261,7 +261,10 @@ class _MusicScreenState extends State<MusicScreen>
),
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