first commit
Build / Build for Android (push) Has been cancelled
Build / Build for iOS (push) Has been cancelled

This commit is contained in:
Zakaria
2026-05-18 14:15:38 -04:00
commit 1563409cb1
382 changed files with 45347 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
import 'package:finamp/components/MusicScreen/album_item_list_tile.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:finamp/services/finamp_settings_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/audio_service_helper.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../screens/artist_screen.dart';
import '../../screens/album_screen.dart';
import '../error_snackbar.dart';
import 'album_item_card.dart';
enum _AlbumListTileMenuItems {
addToQueue,
playNext,
addFavourite,
removeFavourite,
addToMixList,
removeFromMixList,
}
/// This widget is kind of a shell around AlbumItemCard and AlbumItemListTile.
/// Depending on the values given, a list tile or a card will be returned. This
/// widget exists to handle the dropdown stuff and other stuff shared between
/// the two widgets.
class AlbumItem extends StatefulWidget {
const AlbumItem({
Key? key,
required this.album,
this.parentType,
this.onTap,
this.isGrid = false,
this.gridAddSettingsListener = false,
}) : super(key: key);
/// The album (or item, I just used to call items albums before Finamp
/// supported other types) to show in the widget.
final BaseItemDto album;
/// The parent type of the item. Used to change onTap functionality for stuff
/// like artists.
final String? parentType;
/// A custom onTap can be provided to override the default value, which is to
/// open the item's album/artist screen.
final void Function()? onTap;
/// If specified, use cards instead of list tiles. Use this if you want to use
/// this widget in a grid view.
final bool isGrid;
/// If true, the grid item will use a ValueListenableBuilder to check whether
/// or not to show the text. You'll want to set this to false if the
/// [AlbumItem] would be rebuilt by FinampSettings anyway.
final bool gridAddSettingsListener;
@override
State<AlbumItem> createState() => _AlbumItemState();
}
class _AlbumItemState extends State<AlbumItem> {
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
late BaseItemDto mutableAlbum;
late Function() onTap;
@override
void initState() {
super.initState();
mutableAlbum = widget.album;
// this is jank lol
onTap = widget.onTap ??
() {
if (mutableAlbum.type == "MusicArtist" ||
mutableAlbum.type == "MusicGenre") {
Navigator.of(context)
.pushNamed(ArtistScreen.routeName, arguments: mutableAlbum);
} else {
Navigator.of(context)
.pushNamed(AlbumScreen.routeName, arguments: mutableAlbum);
}
};
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
return Padding(
padding: widget.isGrid
? Theme.of(context).cardTheme.margin ?? const EdgeInsets.all(4.0)
: EdgeInsets.zero,
child: GestureDetector(
onLongPressStart: (details) async {
Feedback.forLongPress(context);
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final selection = await showMenu<_AlbumListTileMenuItems>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
screenSize.width - details.globalPosition.dx,
screenSize.height - details.globalPosition.dy,
),
items: [
if (_audioServiceHelper.hasQueueItems()) ...[
PopupMenuItem<_AlbumListTileMenuItems>(
value: _AlbumListTileMenuItems.addToQueue,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.addToQueue),
),
),
PopupMenuItem<_AlbumListTileMenuItems>(
value: _AlbumListTileMenuItems.playNext,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.playNext),
),
),
],
mutableAlbum.userData!.isFavorite
? PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.removeFavourite,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.favorite_border),
title:
Text(AppLocalizations.of(context)!.removeFavourite),
),
)
: PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.addFavourite,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.favorite),
title: Text(AppLocalizations.of(context)!.addFavourite),
),
),
jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id)
? PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.removeFromMixList,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.explore_off),
title:
Text(AppLocalizations.of(context)!.removeFromMix),
),
)
: PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.addToMixList,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.explore),
title: Text(AppLocalizations.of(context)!.addToMix),
),
),
],
);
if (!mounted) return;
switch (selection) {
case _AlbumListTileMenuItems.addToQueue:
List<BaseItemDto>? children;
if (isOffline) {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
// The downloadedParent won't be null here if we've already
// navigated to it in offline mode
final downloadedParent =
downloadsHelper.getDownloadedParent(widget.album.id)!;
children = downloadedParent.downloadedChildren.values.toList();
} else {
children = await jellyfinApiHelper.getItems(
parentItem: widget.album,
sortBy: "ParentIndexNumber,IndexNumber,SortName",
includeItemTypes: "Audio",
isGenres: false,
);
}
if (children != null) {
await _audioServiceHelper.addQueueItems(children);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.addedToQueue),
));
}
break;
case _AlbumListTileMenuItems.playNext:
List<BaseItemDto>? children;
if (isOffline) {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
// The downloadedParent won't be null here if we've already
// navigated to it in offline mode
final downloadedParent =
downloadsHelper.getDownloadedParent(widget.album.id)!;
children = downloadedParent.downloadedChildren.values.toList();
} else {
children = await jellyfinApiHelper.getItems(
parentItem: widget.album,
sortBy: "ParentIndexNumber,IndexNumber,SortName",
includeItemTypes: "Audio",
isGenres: false,
);
}
if (children != null) {
await _audioServiceHelper.insertQueueItemsNext(children);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text(AppLocalizations.of(context)!.insertedIntoQueue),
));
}
break;
case _AlbumListTileMenuItems.addFavourite:
try {
final newUserData =
await jellyfinApiHelper.addFavourite(mutableAlbum.id);
if (!mounted) return;
setState(() {
mutableAlbum.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite added.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case _AlbumListTileMenuItems.removeFavourite:
try {
final newUserData =
await jellyfinApiHelper.removeFavourite(mutableAlbum.id);
if (!mounted) return;
setState(() {
mutableAlbum.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite removed.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case _AlbumListTileMenuItems.addToMixList:
try {
jellyfinApiHelper.addAlbumToMixBuilderList(mutableAlbum);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case _AlbumListTileMenuItems.removeFromMixList:
try {
jellyfinApiHelper.removeAlbumFromBuilderList(mutableAlbum);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case null:
break;
}
},
child: widget.isGrid
? AlbumItemCard(
item: mutableAlbum,
onTap: onTap,
parentType: widget.parentType,
addSettingsListener: widget.gridAddSettingsListener,
)
: AlbumItemListTile(
item: mutableAlbum,
onTap: onTap,
parentType: widget.parentType,
),
),
);
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/generate_subtitle.dart';
import '../album_image.dart';
/// Card content for AlbumItem. You probably shouldn't use this widget directly,
/// use AlbumItem instead.
class AlbumItemCard extends StatelessWidget {
const AlbumItemCard({
Key? key,
required this.item,
this.parentType,
this.onTap,
this.addSettingsListener = false,
}) : super(key: key);
final BaseItemDto item;
final String? parentType;
final void Function()? onTap;
final bool addSettingsListener;
@override
Widget build(BuildContext context) {
return Card(
// In AlbumItem, the OpenContainer handles padding.
margin: EdgeInsets.zero,
child: ClipRRect(
borderRadius: AlbumImage.borderRadius,
child: Stack(
children: [
AlbumImage(item: item),
addSettingsListener
? // We need this ValueListenableBuilder to react to changes to
// showTextOnGridView. When shown in a MusicScreen, this widget
// would refresh anyway since MusicScreen also listens to
// FinampSettings, but there may be cases where this widget is used
// elsewhere.
ValueListenableBuilder<Box<FinampSettings>>(
valueListenable:
FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
if (box.get("FinampSettings")!.showTextOnGridView) {
return _AlbumItemCardText(
item: item, parentType: parentType);
} else {
// ValueListenableBuilder doesn't let us return null, so we
// return a 0-sized SizedBox.
return const SizedBox.shrink();
}
},
)
: FinampSettingsHelper.finampSettings.showTextOnGridView
? _AlbumItemCardText(item: item, parentType: parentType)
: const SizedBox.shrink(),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
),
),
)
],
),
),
);
}
}
class _AlbumItemCardText extends StatelessWidget {
const _AlbumItemCardText({
Key? key,
required this.item,
required this.parentType,
}) : super(key: key);
final BaseItemDto item;
final String? parentType;
@override
Widget build(BuildContext context) {
final subtitle = generateSubtitle(item, parentType, context);
return Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
// We fade from half transparent black to transparent so that text is visible on bright images
Colors.black.withOpacity(0.5),
Colors.transparent,
],
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Align(
alignment: Alignment.bottomLeft,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? "Unknown Name",
overflow: TextOverflow.ellipsis,
maxLines: 3,
style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(color: Colors.white),
),
if (subtitle != null)
Text(
subtitle,
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Colors.white.withOpacity(0.7)),
)
],
),
),
),
),
);
}
}
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/generate_subtitle.dart';
import '../album_image.dart';
/// ListTile content for AlbumItem. You probably shouldn't use this widget
/// directly, use AlbumItem instead.
class AlbumItemListTile extends StatelessWidget {
const AlbumItemListTile({
Key? key,
required this.item,
this.parentType,
this.onTap,
}) : super(key: key);
final BaseItemDto item;
final String? parentType;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final subtitle = generateSubtitle(item, parentType, context);
return ListTile(
// This widget is used on the add to playlist screen, so we allow a custom
// onTap to be passed as an argument.
onTap: onTap,
leading: AlbumImage(item: item),
title: Text(
item.name ?? AppLocalizations.of(context)!.unknownName,
overflow: TextOverflow.ellipsis,
),
subtitle: subtitle == null ? null : Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: jellyfinApiHelper.selectedMixAlbumIds.contains(item.id)
? const Icon(Icons.explore)
: null,
);
}
}
@@ -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));
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../screens/artist_screen.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../album_image.dart';
import '../error_snackbar.dart';
enum ArtistListTileMenuItems {
addToFavourite,
removeFromFavourite,
addToMixList,
removeFromMixList,
}
class ArtistListTile extends StatefulWidget {
const ArtistListTile({
Key? key,
required this.item,
this.index,
this.parentId,
}) : super(key: key);
final BaseItemDto item;
final int? index;
final String? parentId;
@override
State<ArtistListTile> createState() => _ArtistListTileState();
}
class _ArtistListTileState extends State<ArtistListTile> {
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
late BaseItemDto mutableItem = widget.item;
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final listTile = ListTile(
onTap: () {
Navigator.of(context)
.pushNamed(ArtistScreen.routeName, arguments: mutableItem);
},
leading: AlbumImage(item: mutableItem),
title: Text(
mutableItem.name ?? "Unknown Name",
overflow: TextOverflow.ellipsis,
),
subtitle: null,
trailing:
_jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id)
? const Icon(Icons.explore)
: null,
);
return GestureDetector(
onLongPressStart: (details) async {
Feedback.forLongPress(context);
// Some options are disabled in offline mode
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
final selection = await showMenu<ArtistListTileMenuItems>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
screenSize.width - details.globalPosition.dx,
screenSize.height - details.globalPosition.dy,
),
items: [
mutableItem.userData!.isFavorite
? const PopupMenuItem<ArtistListTileMenuItems>(
value: ArtistListTileMenuItems.removeFromFavourite,
child: ListTile(
leading: Icon(Icons.favorite_border),
title: Text("Remove Favourite"),
),
)
: const PopupMenuItem<ArtistListTileMenuItems>(
value: ArtistListTileMenuItems.addToFavourite,
child: ListTile(
leading: Icon(Icons.favorite),
title: Text("Add Favourite"),
),
),
_jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id)
? PopupMenuItem<ArtistListTileMenuItems>(
enabled: !isOffline,
value: ArtistListTileMenuItems.removeFromMixList,
child: ListTile(
leading: const Icon(Icons.explore_off),
title: const Text("Remove From Mix"),
enabled: isOffline ? false : true,
),
)
: PopupMenuItem<ArtistListTileMenuItems>(
value: ArtistListTileMenuItems.addToMixList,
enabled: !isOffline,
child: ListTile(
leading: const Icon(Icons.explore),
title: const Text("Add To Mix"),
enabled: !isOffline,
),
),
],
);
if (!mounted) return;
switch (selection) {
case ArtistListTileMenuItems.addToFavourite:
try {
final newUserData =
await _jellyfinApiHelper.addFavourite(mutableItem.id);
if (!mounted) return;
setState(() {
mutableItem.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite added.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case ArtistListTileMenuItems.removeFromFavourite:
try {
final newUserData =
await _jellyfinApiHelper.removeFavourite(mutableItem.id);
if (!mounted) return;
setState(() {
mutableItem.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite removed.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case ArtistListTileMenuItems.addToMixList:
try {
_jellyfinApiHelper.addArtistToMixBuilderList(mutableItem);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case ArtistListTileMenuItems.removeFromMixList:
try {
_jellyfinApiHelper.removeArtistFromBuilderList(mutableItem);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case null:
break;
}
},
child: listTile);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../services/finamp_user_helper.dart';
import '../../screens/downloads_screen.dart';
import '../../screens/logs_screen.dart';
import '../../screens/settings_screen.dart';
import 'offline_mode_switch_list_tile.dart';
import 'view_list_tile.dart';
class MusicScreenDrawer extends StatelessWidget {
const MusicScreenDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final finampUserHelper = GetIt.instance<FinampUserHelper>();
return Drawer(
child: Scrollbar(
child: CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildListDelegate.fixed(
[
DrawerHeader(
child: Stack(
children: [
const Align(
alignment: Alignment.topCenter,
child: CircleAvatar(
backgroundColor: Colors.transparent,
backgroundImage: AssetImage(
'images/finamp.png',
),
radius: 50.0,
),
),
Align(
alignment:
Alignment.bottomCenter - const Alignment(0, 0.2),
child: Text(
AppLocalizations.of(context)!.finamp,
style: const TextStyle(fontSize: 20),
)),
],
)),
ListTile(
leading: const Icon(Icons.file_download),
title: Text(AppLocalizations.of(context)!.downloads),
onTap: () => Navigator.of(context)
.pushNamed(DownloadsScreen.routeName),
),
const OfflineModeSwitchListTile(),
const Divider(),
],
),
),
// This causes an error when logging out if we show this widget
if (finampUserHelper.currentUser != null)
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return ViewListTile(
view: finampUserHelper.currentUser!.views.values
.elementAt(index));
}, childCount: finampUserHelper.currentUser!.views.length),
),
SliverFillRemaining(
hasScrollBody: false,
child: SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
ListTile(
leading: const Icon(Icons.science),
title: Text(AppLocalizations.of(context)!.redesignBeta),
onTap: () async => await launchUrl(Uri.parse(
"https://github.com/jmshrv/finamp/releases/tag/0.9.2-beta")),
),
ListTile(
leading: const Icon(Icons.warning),
title: Text(AppLocalizations.of(context)!.logs),
onTap: () => Navigator.of(context)
.pushNamed(LogsScreen.routeName),
),
ListTile(
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),
onTap: () => Navigator.of(context)
.pushNamed(SettingsScreen.routeName),
),
],
),
),
),
)
],
),
),
);
}
}
@@ -0,0 +1,614 @@
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;
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/finamp_models.dart';
class OfflineModeSwitchListTile extends StatelessWidget {
const OfflineModeSwitchListTile({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, widget) {
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!.offlineMode),
secondary: const Icon(Icons.cloud_off),
value: box.get("FinampSettings")?.isOffline ?? false,
onChanged: (value) {
FinampSettingsHelper.setIsOffline(value);
},
);
},
);
}
}
@@ -0,0 +1,37 @@
import 'package:finamp/models/finamp_models.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import '../../services/finamp_settings_helper.dart';
class SortByMenuButton extends StatelessWidget {
const SortByMenuButton(this.tabType, {Key? key}) : super(key: key);
final TabContentType tabType;
@override
Widget build(BuildContext context) {
return PopupMenuButton<SortBy>(
icon: const Icon(Icons.sort),
tooltip: AppLocalizations.of(context)!.sortBy,
itemBuilder: (context) => [
for (SortBy sortBy in SortBy.defaults)
PopupMenuItem(
value: sortBy,
child: Text(
sortBy.toLocalisedString(context),
style: TextStyle(
color:
FinampSettingsHelper.finampSettings.getTabSortBy(tabType) ==
sortBy
? Theme.of(context).colorScheme.secondary
: null,
),
),
)
],
onSelected: (value) => FinampSettingsHelper.setSortBy(tabType, value),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class SortOrderButton extends StatelessWidget {
const SortOrderButton(this.tabType, {Key? key}) : super(key: key);
final TabContentType tabType;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, _) {
final finampSettings = box.get("FinampSettings");
return IconButton(
tooltip: AppLocalizations.of(context)!.sortOrder,
icon: finampSettings!.getSortOrder(tabType) == SortOrder.ascending
? const Icon(Icons.arrow_downward)
: const Icon(Icons.arrow_upward),
onPressed: () {
if (finampSettings.getSortOrder(tabType) == SortOrder.ascending) {
FinampSettingsHelper.setSortOrder(tabType,SortOrder.descending);
} else {
FinampSettingsHelper.setSortOrder(tabType, SortOrder.ascending);
}
},
);
},
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/finamp_user_helper.dart';
import '../view_icon.dart';
class ViewListTile extends StatelessWidget {
const ViewListTile({Key? key, required this.view}) : super(key: key);
final BaseItemDto view;
@override
Widget build(BuildContext context) {
final finampUserHelper = GetIt.instance<FinampUserHelper>();
return ListTile(
leading: ViewIcon(
collectionType: view.collectionType,
color: finampUserHelper.currentUser!.currentViewId == view.id
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
view.name ?? "Unknown Name",
style: TextStyle(
color: finampUserHelper.currentUser!.currentViewId == view.id
? Theme.of(context).colorScheme.primary
: null,
),
),
onTap: () => finampUserHelper.setCurrentUserCurrentViewId(view.id),
);
}
}