615 lines
25 KiB
Dart
615 lines
25 KiB
Dart
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 '../../models/finamp_models.dart';
|
|
import '../../models/jellyfin_models.dart';
|
|
import '../../services/downloads_helper.dart';
|
|
import '../../services/finamp_settings_helper.dart';
|
|
import '../../services/finamp_user_helper.dart';
|
|
import '../../services/jellyfin_api_helper.dart';
|
|
import '../AlbumScreen/song_list_tile.dart';
|
|
import '../error_snackbar.dart';
|
|
import '../first_page_progress_indicator.dart';
|
|
import '../new_page_progress_indicator.dart';
|
|
import 'album_item.dart';
|
|
import 'alphabet_item_list.dart';
|
|
|
|
class MusicScreenTabView extends StatefulWidget {
|
|
const MusicScreenTabView({
|
|
Key? key,
|
|
required this.tabContentType,
|
|
this.parentItem,
|
|
this.searchTerm,
|
|
required this.isFavourite,
|
|
this.sortBy,
|
|
this.sortOrder,
|
|
this.view,
|
|
this.albumArtist,
|
|
}) : super(key: key);
|
|
|
|
final TabContentType tabContentType;
|
|
final BaseItemDto? parentItem;
|
|
final String? searchTerm;
|
|
final bool isFavourite;
|
|
final SortBy? sortBy;
|
|
final SortOrder? sortOrder;
|
|
final BaseItemDto? view;
|
|
final String? albumArtist;
|
|
|
|
@override
|
|
State<MusicScreenTabView> createState() => _MusicScreenTabViewState();
|
|
}
|
|
|
|
// We use AutomaticKeepAliveClientMixin so that the view keeps its position after the tab is changed.
|
|
// https://stackoverflow.com/questions/49439047/how-to-preserve-widget-states-in-flutter-when-navigating-using-bottomnavigation
|
|
class _MusicScreenTabViewState extends State<MusicScreenTabView>
|
|
with AutomaticKeepAliveClientMixin<MusicScreenTabView> {
|
|
// If parentItem is null, we assume that this view is actually in a tab.
|
|
// If it isn't null, this view is being used as an artist detail screen and shouldn't be kept alive.
|
|
@override
|
|
bool get wantKeepAlive => widget.parentItem == null;
|
|
|
|
static const _pageSize = 100;
|
|
|
|
final PagingController<int, BaseItemDto> _pagingController =
|
|
PagingController(firstPageKey: 0);
|
|
|
|
List<BaseItemDto>? offlineSortedItems;
|
|
|
|
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
|
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
|
|
|
String? _lastSearch;
|
|
bool? _oldIsFavourite;
|
|
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(
|
|
// starting with Jellyfin 10.9, only automatically created playlists will have a specific library as parent. user-created playlists will not be returned anymore
|
|
// this condition fixes this by not providing a parentId when fetching playlists
|
|
parentItem: widget.tabContentType == TabContentType.playlists
|
|
? null
|
|
: widget.parentItem ??
|
|
widget.view ??
|
|
_finampUserHelper.currentUser?.currentView,
|
|
includeItemTypes: _includeItemTypes(widget.tabContentType),
|
|
|
|
// If we're on the songs tab, sort by "Album,SortName". This is what the
|
|
// Jellyfin web client does. If this isn't the case, check if parentItem
|
|
// is null. parentItem will be null when this widget is not used in an
|
|
// artist view. If it's null, sort by "SortName". If it isn't null, check
|
|
// if the parentItem is a MusicArtist. If it is, sort by year. Otherwise,
|
|
// sort by SortName. If widget.sortBy is set, it is used instead.
|
|
sortBy: widget.sortBy?.jellyfinName(widget.tabContentType) ??
|
|
(widget.tabContentType == TabContentType.songs
|
|
? "Album,SortName"
|
|
: widget.parentItem == null
|
|
? "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,
|
|
filters: widget.isFavourite ? "IsFavorite" : null,
|
|
startIndex: pageKey,
|
|
limit: _pageSize,
|
|
);
|
|
|
|
if (newItems!.length < _pageSize) {
|
|
_pagingController.appendLastPage(newItems);
|
|
} 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!;
|
|
|
|
@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();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
|
|
return ValueListenableBuilder<Box<FinampSettings>>(
|
|
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
|
builder: (context, box, _) {
|
|
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
|
|
|
|
if (isOffline) {
|
|
// We do the same checks we do when online to ensure that the list is
|
|
// not resorted when it doesn't have to be.
|
|
if (widget.searchTerm != _lastSearch ||
|
|
offlineSortedItems == null ||
|
|
widget.isFavourite != _oldIsFavourite ||
|
|
widget.sortBy != _oldSortBy ||
|
|
widget.sortOrder != _oldSortOrder ||
|
|
widget.view != _oldView) {
|
|
_lastSearch = widget.searchTerm;
|
|
_oldIsFavourite = widget.isFavourite;
|
|
_oldSortBy = widget.sortBy;
|
|
_oldSortOrder = widget.sortOrder;
|
|
_oldView = widget.view;
|
|
|
|
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
|
|
|
if (widget.tabContentType == TabContentType.artists) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.cloud_off,
|
|
size: 64,
|
|
color: Colors.white.withOpacity(0.5),
|
|
),
|
|
const Padding(padding: EdgeInsets.all(8.0)),
|
|
const Text("Offline artists view hasn't been implemented")
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (widget.searchTerm == null) {
|
|
if (widget.tabContentType == TabContentType.songs) {
|
|
// If we're on the songs tab, just get all of the downloaded items
|
|
offlineSortedItems = downloadsHelper.downloadedItems
|
|
.where((element) =>
|
|
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()))
|
|
.map((e) => e.item)
|
|
.toList();
|
|
}
|
|
} else {
|
|
if (widget.tabContentType == TabContentType.songs) {
|
|
offlineSortedItems = downloadsHelper.downloadedItems
|
|
.where(
|
|
(element) {
|
|
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);
|
|
},
|
|
)
|
|
.map((e) => e.item)
|
|
.toList();
|
|
}
|
|
}
|
|
|
|
offlineSortedItems!.sort((a, b) {
|
|
// if (a.name == null || b.name == null) {
|
|
// // Returning 0 is the same as both being the same
|
|
// return 0;
|
|
// } else {
|
|
// return a.name!.compareTo(b.name!);
|
|
// }
|
|
if (a.nameForSorting == null || b.nameForSorting == null) {
|
|
// Returning 0 is the same as both being the same
|
|
return 0;
|
|
} else {
|
|
switch (widget.sortBy) {
|
|
case SortBy.sortName:
|
|
if (a.nameForSorting == null || b.nameForSorting == null) {
|
|
// Returning 0 is the same as both being the same
|
|
return 0;
|
|
} else {
|
|
return a.nameForSorting!.compareTo(b.nameForSorting!);
|
|
}
|
|
case SortBy.albumArtist:
|
|
if (a.albumArtist == null || b.albumArtist == null) {
|
|
return 0;
|
|
} else {
|
|
return a.albumArtist!.compareTo(b.albumArtist!);
|
|
}
|
|
case SortBy.communityRating:
|
|
if (a.communityRating == null ||
|
|
b.communityRating == null) {
|
|
return 0;
|
|
} else {
|
|
return a.communityRating!.compareTo(b.communityRating!);
|
|
}
|
|
case SortBy.criticRating:
|
|
if (a.criticRating == null || b.criticRating == null) {
|
|
return 0;
|
|
} else {
|
|
return a.criticRating!.compareTo(b.criticRating!);
|
|
}
|
|
case SortBy.dateCreated:
|
|
if (a.dateCreated == null || b.dateCreated == null) {
|
|
return 0;
|
|
} else {
|
|
return a.dateCreated!.compareTo(b.dateCreated!);
|
|
}
|
|
case SortBy.premiereDate:
|
|
if (a.premiereDate == null || b.premiereDate == null) {
|
|
return 0;
|
|
} else {
|
|
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)
|
|
return Random().nextInt(2) - 1;
|
|
default:
|
|
throw UnimplementedError(
|
|
"Unimplemented offline sort mode ${widget.sortBy}");
|
|
}
|
|
}
|
|
});
|
|
|
|
if (widget.sortOrder == SortOrder.descending) {
|
|
// The above sort functions sort in ascending order, so we swap them
|
|
// when sorting in descending order.
|
|
offlineSortedItems = offlineSortedItems!.reversed.toList();
|
|
}
|
|
}
|
|
|
|
return Scrollbar(
|
|
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,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
box.get("FinampSettings")!.showFastScroller &&
|
|
widget.sortBy == SortBy.sortName
|
|
? AlphabetList(
|
|
callback: scrollToLetter, sortOrder: lastSortOrder)
|
|
: const SizedBox.shrink(),
|
|
],
|
|
),
|
|
);
|
|
} 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.
|
|
// This also means we don't redo a search unless we actaully need to.
|
|
if (widget.searchTerm != _lastSearch ||
|
|
_pagingController.itemList == null ||
|
|
widget.isFavourite != _oldIsFavourite ||
|
|
widget.sortBy != _oldSortBy ||
|
|
widget.sortOrder != _oldSortOrder ||
|
|
widget.view != _oldView) {
|
|
_lastSearch = widget.searchTerm;
|
|
_oldIsFavourite = widget.isFavourite;
|
|
_oldSortBy = widget.sortBy;
|
|
_oldSortOrder = widget.sortOrder;
|
|
_oldView = widget.view;
|
|
_pagingController.refresh();
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
// RefreshIndicator wants an async function, so we use Future.sync()
|
|
// to run refresh() inside an async function
|
|
onRefresh: () => Future.sync(() => _pagingController.refresh()),
|
|
child: Scrollbar(
|
|
controller: controller,
|
|
child: Stack(
|
|
children: [
|
|
box.get("FinampSettings")!.contentViewType ==
|
|
ContentViewType.list
|
|
? 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,
|
|
),
|
|
),
|
|
box.get("FinampSettings")!.showFastScroller &&
|
|
widget.sortBy == SortBy.sortName
|
|
? AlphabetList(
|
|
callback: scrollToLetter, sortOrder: lastSortOrder)
|
|
: const SizedBox.shrink(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
String _includeItemTypes(TabContentType tabContentType) {
|
|
switch (tabContentType) {
|
|
case TabContentType.songs:
|
|
return "Audio";
|
|
case TabContentType.albums:
|
|
return "MusicAlbum";
|
|
case TabContentType.artists:
|
|
return "MusicArtist";
|
|
case TabContentType.genres:
|
|
return "MusicGenre";
|
|
case TabContentType.playlists:
|
|
return "Playlist";
|
|
default:
|
|
throw const FormatException("Unsupported TabContentType");
|
|
}
|
|
}
|
|
|
|
bool _offlineSearch(
|
|
{required BaseItemDto item,
|
|
required String searchTerm,
|
|
required TabContentType tabContentType}) {
|
|
late bool containsName;
|
|
|
|
// This horrible thing is for null safety
|
|
if (item.name == null) {
|
|
containsName = false;
|
|
} else {
|
|
containsName = item.name!.toLowerCase().contains(searchTerm.toLowerCase());
|
|
}
|
|
|
|
return item.type == _includeItemTypes(tabContentType) && containsName;
|
|
}
|