first commit
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mini_music_visualizer/mini_music_visualizer.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../screens/add_to_playlist_screen.dart';
|
||||
import '../../screens/album_screen.dart';
|
||||
import '../../services/audio_service_helper.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/media_state_stream.dart';
|
||||
import '../../services/process_artist.dart';
|
||||
import '../album_image.dart';
|
||||
import '../error_snackbar.dart';
|
||||
import '../favourite_button.dart';
|
||||
import '../print_duration.dart';
|
||||
import 'downloaded_indicator.dart';
|
||||
|
||||
enum SongListTileMenuItems {
|
||||
addToQueue,
|
||||
playNext,
|
||||
replaceQueueWithItem,
|
||||
addToPlaylist,
|
||||
removeFromPlaylist,
|
||||
instantMix,
|
||||
goToAlbum,
|
||||
addFavourite,
|
||||
removeFavourite,
|
||||
}
|
||||
|
||||
class SongListTile extends StatefulWidget {
|
||||
const SongListTile({
|
||||
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.isSong = false,
|
||||
this.showArtists = true,
|
||||
this.onDelete,
|
||||
|
||||
/// Whether this widget is being displayed in a playlist. If true, will show
|
||||
/// the remove from playlist button.
|
||||
this.isInPlaylist = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final List<BaseItemDto>? children;
|
||||
final int? index;
|
||||
final bool isSong;
|
||||
final String? parentId;
|
||||
final bool showArtists;
|
||||
final VoidCallback? onDelete;
|
||||
final bool isInPlaylist;
|
||||
|
||||
@override
|
||||
State<SongListTile> createState() => _SongListTileState();
|
||||
}
|
||||
|
||||
class _SongListTileState extends State<SongListTile> {
|
||||
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
/// Sets the item's favourite on the Jellyfin server.
|
||||
Future<void> setFavourite() async {
|
||||
try {
|
||||
// We switch the widget state before actually doing the request to
|
||||
// make the app feel faster (without, there is a delay from the
|
||||
// user adding the favourite and the icon showing)
|
||||
setState(() {
|
||||
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
|
||||
});
|
||||
|
||||
// Since we flipped the favourite state already, we can use the flipped
|
||||
// state to decide which API call to make
|
||||
final newUserData = widget.item.userData!.isFavorite
|
||||
? await _jellyfinApiHelper.addFavourite(widget.item.id)
|
||||
: await _jellyfinApiHelper.removeFavourite(widget.item.id);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
widget.item.userData = newUserData;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
|
||||
});
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
}
|
||||
|
||||
final listTile = StreamBuilder<MediaState>(
|
||||
stream: mediaStateStream,
|
||||
builder: (context, snapshot) {
|
||||
// I think past me did this check directly from the JSON for
|
||||
// performance. It works for now, apologies if you're debugging it
|
||||
// years in the future.
|
||||
final isCurrentlyPlaying =
|
||||
snapshot.data?.mediaItem?.extras?["itemJson"]["Id"] ==
|
||||
widget.item.id &&
|
||||
snapshot.data?.mediaItem?.extras?["itemJson"]["AlbumId"] ==
|
||||
widget.parentId;
|
||||
|
||||
return 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
|
||||
if (widget.item.indexNumber != null &&
|
||||
!widget.isSong &&
|
||||
widget.item.albumId == widget.parentId)
|
||||
TextSpan(
|
||||
text: "${widget.item.indexNumber}. ",
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).disabledColor)),
|
||||
TextSpan(
|
||||
text: widget.item.name ??
|
||||
AppLocalizations.of(context)!.unknownName,
|
||||
style: TextStyle(
|
||||
color: isCurrentlyPlaying
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
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)),
|
||||
),
|
||||
if (widget.showArtists)
|
||||
TextSpan(
|
||||
text:
|
||||
" · ${processArtist(widget.item.artists?.join(", ") ?? widget.item.albumArtist, context)}",
|
||||
style: TextStyle(color: Theme.of(context).disabledColor),
|
||||
)
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isCurrentlyPlaying &&
|
||||
(snapshot.data?.playbackState.playing ?? false))
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: MiniMusicVisualizer(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
width: 4,
|
||||
height: 15,
|
||||
),
|
||||
),
|
||||
FavoriteButton(
|
||||
item: widget.item,
|
||||
onlyIfFav: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_audioServiceHelper.replaceQueueWithItem(
|
||||
itemList: widget.children ?? [widget.item],
|
||||
initialIndex: widget.index ?? 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) async {
|
||||
Feedback.forLongPress(context);
|
||||
|
||||
// This horrible check does 2 things:
|
||||
// - Checks if the item's album is not the same as the parent item
|
||||
// that created the widget. The ids will be different if the
|
||||
// SongListTile is in a playlist, but they will be the same if viewed
|
||||
// in the item's album. We don't want to show this menu item if we're
|
||||
// already in the item's album.
|
||||
//
|
||||
// - Checks if the album is downloaded if in offline mode. If we're in
|
||||
// offline mode, we need the album to actually be downloaded to show
|
||||
// its metadata. This function also checks if widget.item.parentId is
|
||||
// null.
|
||||
final canGoToAlbum = widget.item.albumId != widget.parentId &&
|
||||
_isAlbumDownloadedIfOffline(widget.item.parentId);
|
||||
|
||||
// Some options are disabled in offline mode
|
||||
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
|
||||
|
||||
final selection = await showMenu<SongListTileMenuItems>(
|
||||
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<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.addToQueue,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.queue_music),
|
||||
title: Text(AppLocalizations.of(context)!.addToQueue),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.playNext,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.queue_music),
|
||||
title: Text(AppLocalizations.of(context)!.playNext),
|
||||
),
|
||||
),
|
||||
],
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.replaceQueueWithItem,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.play_circle),
|
||||
title: Text(AppLocalizations.of(context)!.replaceQueue),
|
||||
),
|
||||
),
|
||||
if (widget.isInPlaylist)
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.addToPlaylist,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(AppLocalizations.of(context)!.addToPlaylistTitle),
|
||||
enabled: !isOffline,
|
||||
),
|
||||
),
|
||||
widget.isInPlaylist
|
||||
? PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.removeFromPlaylist,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.playlist_remove),
|
||||
title: Text(AppLocalizations.of(context)!
|
||||
.removeFromPlaylistTitle),
|
||||
enabled: !isOffline && widget.parentId != null,
|
||||
),
|
||||
)
|
||||
: PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.addToPlaylist,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(
|
||||
AppLocalizations.of(context)!.addToPlaylistTitle),
|
||||
enabled: !isOffline,
|
||||
),
|
||||
),
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.instantMix,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.explore),
|
||||
title: Text(AppLocalizations.of(context)!.instantMix),
|
||||
enabled: !isOffline,
|
||||
),
|
||||
),
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: canGoToAlbum,
|
||||
value: SongListTileMenuItems.goToAlbum,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.album),
|
||||
title: Text(AppLocalizations.of(context)!.goToAlbum),
|
||||
enabled: canGoToAlbum,
|
||||
),
|
||||
),
|
||||
widget.item.userData!.isFavorite
|
||||
? PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.removeFavourite,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.favorite_border),
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.removeFavourite),
|
||||
),
|
||||
)
|
||||
: PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.addFavourite,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.favorite),
|
||||
title: Text(AppLocalizations.of(context)!.addFavourite),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
switch (selection) {
|
||||
case SongListTileMenuItems.addToQueue:
|
||||
await _audioServiceHelper.addQueueItems([widget.item]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.addedToQueue),
|
||||
));
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.playNext:
|
||||
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.insertedIntoQueue),
|
||||
));
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.replaceQueueWithItem:
|
||||
await _audioServiceHelper
|
||||
.replaceQueueWithItem(itemList: [widget.item]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.queueReplaced),
|
||||
));
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.addToPlaylist:
|
||||
Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName,
|
||||
arguments: widget.item.id);
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.removeFromPlaylist:
|
||||
try {
|
||||
await _jellyfinApiHelper.removeItemsFromPlaylist(
|
||||
playlistId: widget.parentId!,
|
||||
entryIds: [widget.item.playlistItemId!]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await _jellyfinApiHelper.getItems(
|
||||
parentItem:
|
||||
await _jellyfinApiHelper.getItemById(widget.item.parentId!),
|
||||
sortBy: "ParentIndexNumber,IndexNumber,SortName",
|
||||
includeItemTypes: "Audio",
|
||||
isGenres: false,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (widget.onDelete != null) widget.onDelete!();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content:
|
||||
Text(AppLocalizations.of(context)!.removedFromPlaylist),
|
||||
));
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.instantMix:
|
||||
await _audioServiceHelper.startInstantMixForItem(widget.item);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.startingInstantMix),
|
||||
));
|
||||
break;
|
||||
case SongListTileMenuItems.goToAlbum:
|
||||
late BaseItemDto album;
|
||||
if (FinampSettingsHelper.finampSettings.isOffline) {
|
||||
// If offline, load the album's BaseItemDto from DownloadHelper.
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
// downloadedParent won't be null here since the menu item already
|
||||
// checks if the DownloadedParent exists.
|
||||
album = downloadsHelper
|
||||
.getDownloadedParent(widget.item.parentId!)!
|
||||
.item;
|
||||
} else {
|
||||
// If online, get the album's BaseItemDto from the server.
|
||||
try {
|
||||
album =
|
||||
await _jellyfinApiHelper.getItemById(widget.item.parentId!);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context)
|
||||
.pushNamed(AlbumScreen.routeName, arguments: album);
|
||||
break;
|
||||
case SongListTileMenuItems.addFavourite:
|
||||
case SongListTileMenuItems.removeFavourite:
|
||||
await setFavourite();
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: widget.isSong
|
||||
? listTile
|
||||
: Dismissible(
|
||||
key: Key(widget.index.toString()),
|
||||
direction: FinampSettingsHelper.finampSettings.disableGesture
|
||||
? DismissDirection.none
|
||||
: DismissDirection.horizontal,
|
||||
background: Container(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.fitHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Icon(
|
||||
Icons.queue_music,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
if (!FinampSettingsHelper.finampSettings.swipeInsertQueueNext) {
|
||||
await _audioServiceHelper.addQueueItems([widget.item]);
|
||||
} else {
|
||||
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
|
||||
}
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
FinampSettingsHelper.finampSettings.swipeInsertQueueNext
|
||||
? AppLocalizations.of(context)!.insertedIntoQueue
|
||||
: AppLocalizations.of(context)!.addedToQueue),
|
||||
));
|
||||
|
||||
return false;
|
||||
},
|
||||
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<DownloadsHelper>();
|
||||
return downloadsHelper.isAlbumDownloaded(albumId);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user