513 lines
19 KiB
Dart
513 lines
19 KiB
Dart
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;
|
|
}
|
|
}
|