diff --git a/lib/components/ArtistScreen/album_list_tile.dart b/lib/components/ArtistScreen/album_list_tile.dart new file mode 100644 index 000000000..56b1fe75c --- /dev/null +++ b/lib/components/ArtistScreen/album_list_tile.dart @@ -0,0 +1,581 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/screens/artist_screen.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.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 '../../services/finamp_settings_helper.dart'; +import '../../services/downloads_helper.dart'; +import '../../services/process_artist.dart'; +import '../../services/music_player_background_task.dart'; +import '../../screens/album_screen.dart'; +import '../../screens/add_to_playlist_screen.dart'; +import '../AlbumScreen/downloaded_indicator.dart'; +import '../favourite_button.dart'; +import '../album_image.dart'; +import '../print_duration.dart'; +import '../error_snackbar.dart'; + +enum AlbumListTileMenuItems { + addFavourite, + removeFavourite, + addToMixList, + removeFromMixList, + playNext, + addToNextUp, + shuffleNext, + shuffleToNextUp, + addToQueue, + shuffleToQueue, +} + +class AlbumListTile extends StatefulWidget { + const AlbumListTile( + {Key? key, + required this.item, + + /// Children that are related to this list tile, such as the other songs in + /// the album. This is used to give the audio service all the songs for the + /// item. If null, only this song will be given to the audio service. + this.children, + + /// Index of the song in whatever parent this widget is in. Used to start + /// the audio service at a certain index, such as when selecting the middle + /// song in an album. + this.index, + this.parentId, + this.parentName, + this.onDelete}) + : super(key: key); + + final BaseItemDto item; + final List? children; + final int? index; + final String? parentId; + final String? parentName; + final VoidCallback? onDelete; + + @override + State createState() => _AlbumListTileState(); +} + +class _AlbumListTileState extends State { + final _audioServiceHelper = GetIt.instance(); + final _queueService = GetIt.instance(); + final _audioHandler = GetIt.instance(); + final _jellyfinApiHelper = GetIt.instance(); + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + + final listTile = ListTile( + leading: AlbumImage(item: widget.item), + title: RichText( + text: TextSpan( + children: [ + // third condition checks if the item is viewed from its album (instead of e.g. a playlist) + // same horrible check as in canGoToAlbum in GestureDetector below + TextSpan( + text: + widget.item.name ?? AppLocalizations.of(context)!.unknownName, + ), + ], + style: Theme.of(context).textTheme.titleMedium, + ), + ), + subtitle: RichText( + text: TextSpan( + children: [ + WidgetSpan( + child: Transform.translate( + offset: const Offset(-3, 0), + child: DownloadedIndicator( + item: widget.item, + size: Theme.of(context).textTheme.bodyMedium!.fontSize! + 3, + ), + ), + alignment: PlaceholderAlignment.top, + ), + TextSpan( + text: printDuration(Duration( + microseconds: (widget.item.runTimeTicks == null + ? 0 + : widget.item.runTimeTicks! ~/ 10))), + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withOpacity(0.7)), + ), + ], + ), + overflow: TextOverflow.ellipsis, + ), + trailing: FavoriteButton( + item: widget.item, + onlyIfFav: true, + ), + onTap: () { + if (widget.item.type == "MusicArtist" || + widget.item.type == "MusicGenre") { + Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: widget.item); + } else { + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: widget.item); + } + }, + ); + + return GestureDetector( + onLongPressStart: (details) async { + Feedback.forLongPress(context); + + // Some options are disabled in offline mode + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final selection = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy, + screenSize.width - details.globalPosition.dx, + screenSize.height - details.globalPosition.dy, + ), + items: [ + widget.item.userData!.isFavorite + ? PopupMenuItem( + value: AlbumListTileMenuItems.removeFavourite, + child: ListTile( + leading: const Icon(Icons.favorite_border), + title: + Text(AppLocalizations.of(context)!.removeFavourite), + ), + ) + : PopupMenuItem( + value: AlbumListTileMenuItems.addFavourite, + child: ListTile( + leading: const Icon(Icons.favorite), + title: Text(AppLocalizations.of(context)!.addFavourite), + ), + ), + _jellyfinApiHelper.selectedMixAlbums.contains(widget.item) + ? PopupMenuItem( + value: AlbumListTileMenuItems.removeFromMixList, + child: ListTile( + leading: const Icon(Icons.explore_off), + title: + Text(AppLocalizations.of(context)!.removeFromMix), + ), + ) + : PopupMenuItem( + value: AlbumListTileMenuItems.addToMixList, + child: ListTile( + leading: const Icon(Icons.explore), + title: Text(AppLocalizations.of(context)!.addToMix), + ), + ), + if (_queueService.getQueue().nextUp.isNotEmpty) + PopupMenuItem( + value: AlbumListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.hourglass_bottom), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), + PopupMenuItem( + value: AlbumListTileMenuItems.addToNextUp, + child: ListTile( + leading: const Icon(Icons.hourglass_top), + title: Text(AppLocalizations.of(context)!.addToNextUp), + ), + ), + if (_queueService.getQueue().nextUp.isNotEmpty) + PopupMenuItem( + value: AlbumListTileMenuItems.shuffleNext, + child: ListTile( + leading: const Icon(Icons.hourglass_bottom), + title: Text(AppLocalizations.of(context)!.shuffleNext), + ), + ), + PopupMenuItem( + value: AlbumListTileMenuItems.shuffleToNextUp, + child: ListTile( + leading: const Icon(Icons.hourglass_top), + title: Text(AppLocalizations.of(context)!.shuffleToNextUp), + ), + ), + PopupMenuItem( + value: AlbumListTileMenuItems.addToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.addToQueue), + ), + ), + PopupMenuItem( + value: AlbumListTileMenuItems.shuffleToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.shuffleToQueue), + ), + ), + ], + ); + + if (!mounted) return; + + switch (selection) { + case AlbumListTileMenuItems.addFavourite: + try { + final newUserData = + await _jellyfinApiHelper.addFavourite(widget.item.id); + + if (!mounted) return; + + setState(() { + widget.item.userData = newUserData; + }); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.removeFavourite: + try { + final newUserData = + await _jellyfinApiHelper.removeFavourite(widget.item.id); + + if (!mounted) return; + + setState(() { + widget.item.userData = newUserData; + }); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.addToMixList: + try { + _jellyfinApiHelper.addAlbumToMixBuilderList(widget.item); + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.removeFromMixList: + try { + _jellyfinApiHelper.removeAlbumFromMixBuilderList(widget.item); + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.playNext: + try { + List? albumTracks = + await _jellyfinApiHelper.getItems( + parentItem: widget.item, + isGenres: false, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Couldn't load album."), + ), + ); + return; + } + + _queueService.addNext( + items: albumTracks, + source: QueueItemSource( + type: widget.item.type == "Playlist" + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: widget.item.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: widget.item.id, + item: widget.item, + )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.confirmPlayNext( + widget.item.type == "Playlist" ? "playlist" : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.addToNextUp: + try { + List? albumTracks = + await _jellyfinApiHelper.getItems( + parentItem: widget.item, + isGenres: false, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.item.type == "Playlist" ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToNextUp( + items: albumTracks, + source: QueueItemSource( + type: widget.item.type == "Playlist" + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: widget.item.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: widget.item.id, + item: widget.item, + )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .confirmAddToNextUp(widget.item.type == "Playlist" + ? "playlist" + : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.shuffleNext: + try { + List? albumTracks = + await _jellyfinApiHelper.getItems( + parentItem: widget.item, + isGenres: false, + sortBy: "Random", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.item.type == "Playlist" ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addNext( + items: albumTracks, + source: QueueItemSource( + type: widget.item.type == "Playlist" + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: widget.item.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: widget.item.id, + item: widget.item, + )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.confirmPlayNext( + widget.item.type == "Playlist" ? "playlist" : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.shuffleToNextUp: + try { + List? albumTracks = + await _jellyfinApiHelper.getItems( + parentItem: widget.item, + isGenres: false, + sortBy: + "Random", //TODO this isn't working anymore with Jellyfin 10.9 (unstable) + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.item.type == "Playlist" ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToNextUp( + items: albumTracks, + source: QueueItemSource( + type: widget.item.type == "Playlist" + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: widget.item.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: widget.item.id, + item: widget.item, + )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.confirmShuffleToNextUp), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.addToQueue: + try { + List? albumTracks = + await _jellyfinApiHelper.getItems( + parentItem: widget.item, + isGenres: false, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.item.type == "Playlist" ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToQueue( + items: albumTracks, + source: QueueItemSource( + type: widget.item.type == "Playlist" + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: widget.item.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: widget.item.id, + item: widget.item, + )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .confirmAddToQueue(widget.item.type == "Playlist" + ? "playlist" + : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case AlbumListTileMenuItems.shuffleToQueue: + try { + List? albumTracks = + await _jellyfinApiHelper.getItems( + parentItem: widget.item, + isGenres: false, + sortBy: "Random", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.item.type == "Playlist" ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToQueue( + items: albumTracks, + source: QueueItemSource( + type: widget.item.type == "Playlist" + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: widget.item.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: widget.item.id, + item: widget.item, + )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .confirmAddToQueue(widget.item.type == "Playlist" + ? "playlist" + : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case null: + break; + } + }, + child: listTile); + } +} + +/// If offline, check if an album is downloaded. Always returns true if online. +/// Returns false if albumId is null. +bool _isAlbumDownloadedIfOffline(String? albumId) { + if (albumId == null) { + return false; + } else if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsHelper = GetIt.instance(); + return downloadsHelper.isAlbumDownloaded(albumId); + } else { + return true; + } +} diff --git a/lib/components/ArtistScreen/albums_sliver_list.dart b/lib/components/ArtistScreen/albums_sliver_list.dart new file mode 100644 index 000000000..9d709d455 --- /dev/null +++ b/lib/components/ArtistScreen/albums_sliver_list.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../AlbumScreen/album_screen_content.dart'; +import 'album_list_tile.dart'; + +class AlbumsSliverList extends StatefulWidget { + const AlbumsSliverList({ + Key? key, + required this.childrenForList, + required this.parent, + this.onDelete, + }) : super(key: key); + + final List childrenForList; + final BaseItemDto parent; + final BaseItemDtoCallback? onDelete; + + @override + State createState() => _AlbumsSliverListState(); +} + +class _AlbumsSliverListState extends State { + final GlobalKey sliverListKey = + GlobalKey(); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final BaseItemDto item = widget.childrenForList[index]; + + BaseItemDto removeItem() { + late BaseItemDto item; + + setState(() { + item = widget.childrenForList.removeAt(index); + }); + + return item; + } + + return AlbumListTile( + item: item, + children: widget.childrenForList, + index: index, + parentId: widget.parent.id, + parentName: widget.parent.name, + onDelete: () { + final item = removeItem(); + if (widget.onDelete != null) { + widget.onDelete!(item); + } + }, + // show artists except for this one scenario + // TODO we could do it here like with the song sliver list + // showArtists: false, + ); + }, + childCount: widget.childrenForList.length, + ), + ); + } +} diff --git a/lib/components/ArtistScreen/artist_screen_content.dart b/lib/components/ArtistScreen/artist_screen_content.dart index 59cfe3c1f..33ab415ff 100644 --- a/lib/components/ArtistScreen/artist_screen_content.dart +++ b/lib/components/ArtistScreen/artist_screen_content.dart @@ -10,6 +10,7 @@ import '../../services/jellyfin_api_helper.dart'; import '../AlbumScreen/album_screen_content.dart'; import '../AlbumScreen/song_list_tile.dart'; import '../MusicScreen/music_screen_tab_view.dart'; +import 'albums_sliver_list.dart'; class ArtistScreenContent extends StatefulWidget { const ArtistScreenContent({Key? key, required this.parent}) : super(key: key); @@ -22,6 +23,7 @@ class ArtistScreenContent extends StatefulWidget { class _ArtistScreenContentState extends State { Future?>? songs; + Future?>? albums; JellyfinApiHelper jellyfinApiHelper = GetIt.instance(); @override @@ -36,38 +38,50 @@ class _ArtistScreenContentState extends State { return FutureBuilder( future: songs, - builder: (context, snapshot) { - var orderedSongs = snapshot.data?.reversed.toList() ?? []; + builder: (context, songSnapshot) { + var orderedSongs = songSnapshot.data?.reversed.toList() ?? []; - return Scrollbar( - child: NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - const SliverPadding(padding: EdgeInsets.fromLTRB(0, 15.0, 0, 0)), - SliverToBoxAdapter( - child: Text( - AppLocalizations.of(context)!.topSongs, - style: - const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - )), - SongsSliverList( - childrenForList: orderedSongs.take(5).toList(), - childrenForQueue: orderedSongs, - showPlayCount: true, - parent: widget.parent, - ), - const SliverPadding(padding: EdgeInsets.fromLTRB(0, 15.0, 0, 0)), - SliverToBoxAdapter( - child: Text( - AppLocalizations.of(context)!.albums, - style: - const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - )), - ], - body: MusicScreenTabView( - tabContentType: TabContentType.albums, - parentItem: widget.parent, - isFavourite: false), - )); + albums ??= jellyfinApiHelper.getItems( + parentItem: widget.parent, + filters: "Artist=${widget.parent.name}", + sortBy: "PlayCount", + includeItemTypes: "MusicAlbum", + isGenres: false, + ); + + return FutureBuilder( + future: albums, + builder: (context, albumSnapshot) { + return Scrollbar( + child: CustomScrollView(slivers: [ + const SliverPadding( + padding: EdgeInsets.fromLTRB(0, 15.0, 0, 0)), + SliverToBoxAdapter( + child: Text( + AppLocalizations.of(context)!.topSongs, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + )), + SongsSliverList( + childrenForList: orderedSongs.take(5).toList(), + childrenForQueue: orderedSongs, + showPlayCount: true, + parent: widget.parent, + ), + const SliverPadding( + padding: EdgeInsets.fromLTRB(0, 15.0, 0, 0)), + SliverToBoxAdapter( + child: Text( + AppLocalizations.of(context)!.albums, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + )), + AlbumsSliverList( + childrenForList: albumSnapshot.data ?? [], + parent: widget.parent, + ), + ])); + }); }); } }