first commit
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user