Download bug fixes, resuming, lyrics, explicit marking

This commit is contained in:
exttex 2020-09-09 20:50:15 +02:00
parent b9004c3004
commit a494601ab0
10 changed files with 206 additions and 64 deletions

View File

@ -3,6 +3,8 @@ package com.ryanheise.just_audio;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Handler; import android.os.Handler;
import android.util.Log;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackParameters;
@ -31,7 +33,6 @@ import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.Util;
import io.flutter.Log;
import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.EventChannel.EventSink; import io.flutter.plugin.common.EventChannel.EventSink;
@ -446,7 +447,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met
case "progressive": case "progressive":
Uri uri = Uri.parse((String)map.get("uri")); Uri uri = Uri.parse((String)map.get("uri"));
//Deezer //Deezer
if (uri.getHost().contains("dzcdn.net")) { if (uri.getHost() != null && uri.getHost().contains("dzcdn.net")) {
//Track id is stored in URL fragment (after #) //Track id is stored in URL fragment (after #)
String fragment = uri.getFragment(); String fragment = uri.getFragment();
uri = Uri.parse(((String)map.get("uri")).replace("#" + fragment, "")); uri = Uri.parse(((String)map.get("uri")).replace("#" + fragment, ""));

View File

@ -64,7 +64,7 @@ class DeezerAPI {
'gateway_input': gatewayInput 'gateway_input': gatewayInput
}, },
data: jsonEncode(params??{}), data: jsonEncode(params??{}),
options: Options(responseType: ResponseType.json, sendTimeout: 5000, receiveTimeout: 5000) options: Options(responseType: ResponseType.json, sendTimeout: 10000, receiveTimeout: 10000)
); );
return response.data; return response.data;
} }
@ -73,7 +73,7 @@ class DeezerAPI {
Dio dio = Dio(); Dio dio = Dio();
Response response = await dio.get( Response response = await dio.get(
'https://api.deezer.com/' + path, 'https://api.deezer.com/' + path,
options: Options(responseType: ResponseType.json, sendTimeout: 5000, receiveTimeout: 5000) options: Options(responseType: ResponseType.json, sendTimeout: 10000, receiveTimeout: 10000)
); );
return response.data; return response.data;
} }

View File

@ -33,11 +33,12 @@ class Track {
//TODO: Not in DB //TODO: Not in DB
int diskNumber; int diskNumber;
bool explicit;
List<dynamic> playbackDetails; List<dynamic> playbackDetails;
Track({this.id, this.title, this.duration, this.album, this.playbackDetails, this.albumArt, Track({this.id, this.title, this.duration, this.album, this.playbackDetails, this.albumArt,
this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite, this.diskNumber}); this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite, this.diskNumber, this.explicit});
String get artistString => artists.map<String>((art) => art.name).join(', '); String get artistString => artists.map<String>((art) => art.name).join(', ');
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}"; String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
@ -134,7 +135,8 @@ class Track {
playbackDetails: [json['MD5_ORIGIN'], json['MEDIA_VERSION']], playbackDetails: [json['MD5_ORIGIN'], json['MEDIA_VERSION']],
lyrics: Lyrics(id: json['LYRICS_ID'].toString()), lyrics: Lyrics(id: json['LYRICS_ID'].toString()),
favorite: favorite, favorite: favorite,
diskNumber: int.parse(json['DISK_NUMBER']??'1') diskNumber: int.parse(json['DISK_NUMBER']??'1'),
explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true:false
); );
} }
Map<String, dynamic> toSQL({off = false}) => { Map<String, dynamic> toSQL({off = false}) => {
@ -470,15 +472,17 @@ class Lyrics {
class Lyric { class Lyric {
Duration offset; Duration offset;
String text; String text;
String lrcTimestamp;
Lyric({this.offset, this.text}); Lyric({this.offset, this.text, this.lrcTimestamp});
//JSON //JSON
factory Lyric.fromPrivateJson(Map<dynamic, dynamic> json) { factory Lyric.fromPrivateJson(Map<dynamic, dynamic> json) {
if (json['milliseconds'] == null || json['line'] == null) return Lyric(); //Empty lyric if (json['milliseconds'] == null || json['line'] == null) return Lyric(); //Empty lyric
return Lyric( return Lyric(
offset: Duration(milliseconds: int.parse(json['milliseconds'].toString())), offset: Duration(milliseconds: int.parse(json['milliseconds'].toString())),
text: json['line'] text: json['line'],
lrcTimestamp: json['lrc_timestamp']
); );
} }

View File

@ -31,6 +31,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) {
: Lyrics.fromJson(json['lyrics'] as Map<String, dynamic>), : Lyrics.fromJson(json['lyrics'] as Map<String, dynamic>),
favorite: json['favorite'] as bool, favorite: json['favorite'] as bool,
diskNumber: json['diskNumber'] as int, diskNumber: json['diskNumber'] as int,
explicit: json['explicit'] as bool,
); );
} }
@ -46,6 +47,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'lyrics': instance.lyrics, 'lyrics': instance.lyrics,
'favorite': instance.favorite, 'favorite': instance.favorite,
'diskNumber': instance.diskNumber, 'diskNumber': instance.diskNumber,
'explicit': instance.explicit,
'playbackDetails': instance.playbackDetails, 'playbackDetails': instance.playbackDetails,
}; };
@ -244,12 +246,14 @@ Lyric _$LyricFromJson(Map<String, dynamic> json) {
? null ? null
: Duration(microseconds: json['offset'] as int), : Duration(microseconds: json['offset'] as int),
text: json['text'] as String, text: json['text'] as String,
lrcTimestamp: json['lrcTimestamp'] as String,
); );
} }
Map<String, dynamic> _$LyricToJson(Lyric instance) => <String, dynamic>{ Map<String, dynamic> _$LyricToJson(Lyric instance) => <String, dynamic>{
'offset': instance.offset?.inMicroseconds, 'offset': instance.offset?.inMicroseconds,
'text': instance.text, 'text': instance.text,
'lrcTimestamp': instance.lrcTimestamp,
}; };
QueueSource _$QueueSourceFromJson(Map<String, dynamic> json) { QueueSource _$QueueSourceFromJson(Map<String, dynamic> json) {

View File

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:disk_space/disk_space.dart'; import 'package:disk_space/disk_space.dart';
import 'package:ext_storage/ext_storage.dart'; import 'package:ext_storage/ext_storage.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -30,7 +32,7 @@ class DownloadManager {
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;
bool _cancelNotifications = true; bool _cancelNotifications = true;
bool get stopped => queue.length > 0 && _download == null; bool stopped = true;
Future init() async { Future init() async {
//Prepare DB //Prepare DB
@ -115,7 +117,7 @@ class DownloadManager {
//Update queue, start new download //Update queue, start new download
void updateQueue() async { void updateQueue() async {
if (_download == null && queue.length > 0) { if (_download == null && queue.length > 0 && !stopped) {
_download = queue[0].download( _download = queue[0].download(
onDone: () async { onDone: () async {
//On download finished //On download finished
@ -137,10 +139,12 @@ class DownloadManager {
updateQueue(); updateQueue();
} }
).catchError((e, st) async { ).catchError((e, st) async {
if (stopped) return;
print('Download error: $e\n$st'); print('Download error: $e\n$st');
//Catch download errors //Catch download errors
_download = null; _download = null;
_cancelNotifications = true; _cancelNotifications = true;
//Cancellation error i guess
await _showError(); await _showError();
}); });
//Show download progress notifications //Show download progress notifications
@ -148,6 +152,22 @@ class DownloadManager {
} }
} }
//Stop downloading and end my life
Future stop() async {
stopped = true;
if (_download != null) {
await queue[0].stop();
}
_download = null;
}
//Start again downloads
Future start() async {
if (_download != null) return;
stopped = false;
updateQueue();
}
//Show error notification //Show error notification
Future _showError() async { Future _showError() async {
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
@ -290,18 +310,13 @@ class DownloadManager {
return d.path; return d.path;
} }
Future addOfflineTrack(Track track, {private = true}) async { Future addOfflineTrack(Track track, {private = true, forceStart = true}) async {
//Paths //Paths
String path = p.join(_offlinePath, track.id); String path = p.join(_offlinePath, track.id);
if (track.playbackDetails == null) { if (track.playbackDetails == null) {
//Get track from API if download info missing //Get track from API if download info missing
track = await deezerAPI.track(track.id); track = await deezerAPI.track(track.id);
} }
//Load lyrics
try {
Lyrics l = await deezerAPI.lyrics(track.id);
track.lyrics = l;
} catch (e) {}
String url = track.getUrl(settings.getQualityInt(settings.offlineQuality)); String url = track.getUrl(settings.getQualityInt(settings.offlineQuality));
if (!private) { if (!private) {
@ -316,6 +331,12 @@ class DownloadManager {
if (settings.downloadQuality == AudioQuality.FLAC) { if (settings.downloadQuality == AudioQuality.FLAC) {
path = 'flac'; path = 'flac';
} }
} else {
//Load lyrics for private
try {
Lyrics l = await deezerAPI.lyrics(track.id);
track.lyrics = l;
} catch (e) {}
} }
Download download = Download(track: track, path: path, url: url, private: private); Download download = Download(track: track, path: path, url: url, private: private);
@ -339,7 +360,7 @@ class DownloadManager {
await b.commit(); await b.commit();
queue.add(download); queue.add(download);
updateQueue(); if (forceStart) start();
} }
Future addOfflineAlbum(Album album, {private = true}) async { Future addOfflineAlbum(Album album, {private = true}) async {
@ -353,8 +374,9 @@ class DownloadManager {
} }
//Save all tracks //Save all tracks
for (Track track in album.tracks) { for (Track track in album.tracks) {
await addOfflineTrack(track, private: private); await addOfflineTrack(track, private: private, forceStart: false);
} }
start();
} }
//Add offline playlist, can be also used as update //Add offline playlist, can be also used as update
@ -370,8 +392,9 @@ class DownloadManager {
} }
//Download all tracks //Download all tracks
for (Track t in playlist.tracks) { for (Track t in playlist.tracks) {
await addOfflineTrack(t, private: private); await addOfflineTrack(t, private: private, forceStart: false);
} }
start();
} }
@ -465,8 +488,13 @@ class DownloadManager {
//Delete queue //Delete queue
Future clearQueue() async { Future clearQueue() async {
for (int i=queue.length-1; i>0; i--) { while (queue.length > 0) {
await removeDownload(queue[i]); if (queue.length == 1) {
if (_download != null) break;
await removeDownload(queue[0]);
return;
}
await removeDownload(queue[1]);
} }
} }
@ -485,23 +513,39 @@ class Download {
DownloadState state; DownloadState state;
String _cover; String _cover;
//For canceling
IOSink _outSink;
CancelToken _cancel;
StreamSubscription _progressSub;
int received = 0; int received = 0;
int total = 1; int total = 1;
Download({this.track, this.path, this.url, this.private, this.state = DownloadState.NONE}); Download({this.track, this.path, this.url, this.private, this.state = DownloadState.NONE});
//Stop download
Future stop() async {
if (_cancel != null) _cancel.cancel();
//if (_outSink != null) _outSink.close();
if (_progressSub != null) _progressSub.cancel();
received = 0;
total = 1;
state = DownloadState.NONE;
}
Future download({onDone}) async { Future download({onDone}) async {
Dio dio = Dio(); Dio dio = Dio();
//TODO: Check for internet before downloading //TODO: Check for internet before downloading
if (!this.private) { if (!this.private && !(this.path.endsWith('.mp3') || this.path.endsWith('.flac'))) {
String ext = this.path; String ext = this.path;
//Get track details //Get track details
Map rawTrack = (await deezerAPI.callApi('song.getListData', params: {'sng_ids': [track.id]}))['results']['data'][0]; Map _rawTrackData = await deezerAPI.callApi('song.getListData', params: {'sng_ids': [track.id]});
Map rawTrack = _rawTrackData['results']['data'][0];
this.track = Track.fromPrivateJson(rawTrack); this.track = Track.fromPrivateJson(rawTrack);
//Get path if public //Get path if public
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]'); RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
//Download path //Download path
@ -533,6 +577,9 @@ class Download {
//Create filename //Create filename
String _filename = settings.downloadFilename; String _filename = settings.downloadFilename;
//Feats filter
String feats = '';
if (track.artists.length > 1) feats = "feat. ${track.artists.sublist(1).map((a) => a.name).join(', ')}";
//Filters //Filters
Map<String, String> vars = { Map<String, String> vars = {
'%artists%': track.artistString.replaceAll(sanitize, ''), '%artists%': track.artistString.replaceAll(sanitize, ''),
@ -540,7 +587,8 @@ class Download {
'%title%': track.title.replaceAll(sanitize, ''), '%title%': track.title.replaceAll(sanitize, ''),
'%album%': track.album.title.replaceAll(sanitize, ''), '%album%': track.album.title.replaceAll(sanitize, ''),
'%trackNumber%': track.trackNumber.toString(), '%trackNumber%': track.trackNumber.toString(),
'%0trackNumber%': track.trackNumber.toString().padLeft(2, '0') '%0trackNumber%': track.trackNumber.toString().padLeft(2, '0'),
'%feats%': feats
}; };
//Replace //Replace
vars.forEach((key, value) { vars.forEach((key, value) {
@ -553,15 +601,48 @@ class Download {
//Download //Download
this.state = DownloadState.DOWNLOADING; this.state = DownloadState.DOWNLOADING;
await dio.download( //Create download file
File downloadFile = File(this.path + '.ENC');
//Get start position
int start = 0;
if (await downloadFile.exists()) {
FileStat stat = await downloadFile.stat();
start = stat.size;
} else {
//Create file if doesnt exist
await downloadFile.create(recursive: true);
}
//Download
_cancel = CancelToken();
Response response = await dio.get(
this.url, this.url,
this.path + '.ENC', options: Options(
deleteOnError: true, responseType: ResponseType.stream,
onReceiveProgress: (rec, total) { headers: {
this.received = rec; 'Range': 'bytes=$start-'
this.total = total; },
} ),
cancelToken: _cancel
); );
//Size
this.total = int.parse(response.headers['Content-Length'][0]) + start;
this.received = start;
//Save
_outSink = downloadFile.openWrite(mode: FileMode.append);
Stream<Uint8List> _data = response.data.stream.asBroadcastStream();
_progressSub = _data.listen((Uint8List c) {
this.received += c.length;
});
//Pipe to file
try {
await _outSink.addStream(_data);
} catch (e) {
await _outSink.close();
throw Exception('Download error');
}
await _outSink.close();
_cancel = null;
this.state = DownloadState.POST; this.state = DownloadState.POST;
//Decrypt //Decrypt
@ -586,6 +667,28 @@ class Download {
//Remove encrypted //Remove encrypted
await File(path + '.ENC').delete(); await File(path + '.ENC').delete();
if (!settings.albumFolder) await File(_cover).delete(); if (!settings.albumFolder) await File(_cover).delete();
//Get lyrics
Lyrics lyrics;
try {
lyrics = await deezerAPI.lyrics(track.id);
} catch (e) {}
if (lyrics != null && lyrics.lyrics != null) {
//Create .LRC file
String lrcPath = p.join(p.dirname(path), p.basenameWithoutExtension(path)) + '.lrc';
File lrcFile = File(lrcPath);
String lrcData = '';
//Generate file
lrcData += '[ar:${track.artistString}]\r\n';
lrcData += '[al:${track.album.title}]\r\n';
lrcData += '[ti:${track.title}]\r\n';
for (Lyric l in lyrics.lyrics) {
if (l.lrcTimestamp != null && l.lrcTimestamp != '' && l.text != null)
lrcData += '${l.lrcTimestamp}${l.text}\r\n';
}
lrcFile.writeAsString(lrcData);
}
this.state = DownloadState.DONE; this.state = DownloadState.DONE;
onDone(); onDone();
return; return;

View File

@ -309,12 +309,13 @@ class AudioPlayerTask extends BackgroundAudioTask {
MediaControl.skipToPrevious, MediaControl.skipToPrevious,
if (_player.playing) MediaControl.pause else MediaControl.play, if (_player.playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext, MediaControl.skipToNext,
//MediaControl.stop MediaControl.stop
], ],
systemActions: [ systemActions: [
MediaAction.seekTo, MediaAction.seekTo,
MediaAction.seekForward, MediaAction.seekForward,
MediaAction.seekBackward MediaAction.seekBackward,
//MediaAction.stop
], ],
processingState: _getProcessingState(), processingState: _getProcessingState(),
playing: _player.playing, playing: _player.playing,

View File

@ -52,6 +52,7 @@ class DownloadTile extends StatelessWidget {
subtitle: Text(subtitle), subtitle: Text(subtitle),
leading: CachedImage( leading: CachedImage(
url: download.track.albumArt.thumb, url: download.track.albumArt.thumb,
width: 48.0,
), ),
trailing: trailing, trailing: trailing,
onTap: () { onTap: () {
@ -102,30 +103,12 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
title: Text('Downloads'), title: Text('Downloads'),
actions: [ actions: [
IconButton( IconButton(
icon: Icon(Icons.delete_sweep), icon: Icon(downloadManager.stopped ? Icons.play_arrow : Icons.stop),
onPressed: () { onPressed: () {
showDialog( setState(() {
context: context, if (downloadManager.stopped) downloadManager.start();
builder: (context) { else downloadManager.stop();
return AlertDialog( });
title: Text('Delete'),
content: Text('Are you sure, you want to delete all queued downloads?'),
actions: [
FlatButton(
child: Text('Cancel'),
onPressed: () => Navigator.of(context).pop(),
),
FlatButton(
child: Text('Delete'),
onPressed: () async {
await downloadManager.clearQueue();
Navigator.of(context).pop();
},
)
],
);
}
);
}, },
) )
], ],
@ -140,9 +123,41 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
return Container(width: 0, height: 0,); return Container(width: 0, height: 0,);
return Column( return Column(
children: List.generate(downloadManager.queue.length, (i) { children: [
return DownloadTile(downloadManager.queue[i], onDelete: () => setState(() => {})); ...List.generate(downloadManager.queue.length, (i) {
}) return DownloadTile(downloadManager.queue[i], onDelete: () => setState(() => {}));
}),
if (downloadManager.queue.length > 1 || (downloadManager.stopped && downloadManager.queue.length > 0))
ListTile(
title: Text('Clear queue'),
subtitle: Text("This won't delete currently downloading item"),
leading: Icon(Icons.delete),
onTap: () async {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Delete'),
content: Text('Are you sure, you want to delete all queued downloads?'),
actions: [
FlatButton(
child: Text('Cancel'),
onPressed: () => Navigator.of(context).pop(),
),
FlatButton(
child: Text('Delete'),
onPressed: () async {
await downloadManager.clearQueue();
Navigator.of(context).pop();
},
)
],
);
}
);
},
)
]
); );
}, },
), ),

View File

@ -56,20 +56,20 @@ class LibraryScreen extends StatelessWidget {
body: ListView( body: ListView(
children: <Widget>[ children: <Widget>[
Container(height: 4.0,), Container(height: 4.0,),
if (downloadManager.stopped) if (downloadManager.stopped && downloadManager.queue.length > 0)
ListTile( ListTile(
title: Text('Downloads'), title: Text('Downloads'),
leading: Icon(Icons.file_download), leading: Icon(Icons.file_download),
subtitle: Text('Downloading is currently stopped, click here to resume.'), subtitle: Text('Downloading is currently stopped, click here to resume.'),
onTap: () { onTap: () {
downloadManager.updateQueue(); downloadManager.start();
Navigator.of(context).push(MaterialPageRoute( Navigator.of(context).push(MaterialPageRoute(
builder: (context) => DownloadsScreen() builder: (context) => DownloadsScreen()
)); ));
}, },
), ),
//Dirty if to not use columns //Dirty if to not use columns
if (downloadManager.stopped) if (downloadManager.stopped && downloadManager.queue.length > 0)
Divider(), Divider(),
ListTile( ListTile(

View File

@ -497,7 +497,7 @@ class _GeneralSettingsState extends State<GeneralSettings> {
), ),
Container(height: 8.0), Container(height: 8.0),
Text( Text(
'Valid variables are: %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%', 'Valid variables are: %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%, %feats%',
style: TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
), ),

View File

@ -49,6 +49,7 @@ class _TrackTileState extends State<TrackTile> {
title: Text( title: Text(
widget.track.title, widget.track.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.clip,
style: TextStyle( style: TextStyle(
color: nowPlaying?Theme.of(context).primaryColor:null color: nowPlaying?Theme.of(context).primaryColor:null
), ),
@ -59,12 +60,23 @@ class _TrackTileState extends State<TrackTile> {
), ),
leading: CachedImage( leading: CachedImage(
url: widget.track.albumArt.thumb, url: widget.track.albumArt.thumb,
width: 48,
), ),
onTap: widget.onTap, onTap: widget.onTap,
onLongPress: widget.onHold, onLongPress: widget.onHold,
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.track.explicit??false)
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.0),
child: Text(
'E',
style: TextStyle(
color: Colors.red
),
),
),
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0), padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Text(widget.track.durationString), child: Text(widget.track.durationString),
@ -98,6 +110,7 @@ class AlbumTile extends StatelessWidget {
), ),
leading: CachedImage( leading: CachedImage(
url: album.art.thumb, url: album.art.thumb,
width: 48,
), ),
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: onHold,
@ -172,6 +185,7 @@ class PlaylistTile extends StatelessWidget {
), ),
leading: CachedImage( leading: CachedImage(
url: playlist.image.thumb, url: playlist.image.thumb,
width: 48,
), ),
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: onHold,