From a494601ab0695cac67a105b9b08b442cfd29a9a3 Mon Sep 17 00:00:00 2001 From: exttex Date: Wed, 9 Sep 2020 20:50:15 +0200 Subject: [PATCH] Download bug fixes, resuming, lyrics, explicit marking --- .../com/ryanheise/just_audio/AudioPlayer.java | 5 +- lib/api/deezer.dart | 4 +- lib/api/definitions.dart | 12 +- lib/api/definitions.g.dart | 4 + lib/api/download.dart | 151 +++++++++++++++--- lib/api/player.dart | 5 +- lib/ui/downloads_screen.dart | 67 +++++--- lib/ui/library.dart | 6 +- lib/ui/settings_screen.dart | 2 +- lib/ui/tiles.dart | 14 ++ 10 files changed, 206 insertions(+), 64 deletions(-) diff --git a/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java b/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java index f3418f0..43b1118 100644 --- a/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java +++ b/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java @@ -3,6 +3,8 @@ package com.ryanheise.just_audio; import android.content.Context; import android.net.Uri; import android.os.Handler; +import android.util.Log; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; 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.HttpDataSource; import com.google.android.exoplayer2.util.Util; -import io.flutter.Log; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel.EventSink; @@ -446,7 +447,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met case "progressive": Uri uri = Uri.parse((String)map.get("uri")); //Deezer - if (uri.getHost().contains("dzcdn.net")) { + if (uri.getHost() != null && uri.getHost().contains("dzcdn.net")) { //Track id is stored in URL fragment (after #) String fragment = uri.getFragment(); uri = Uri.parse(((String)map.get("uri")).replace("#" + fragment, "")); diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index 92b3102..9e0f1be 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -64,7 +64,7 @@ class DeezerAPI { 'gateway_input': gatewayInput }, data: jsonEncode(params??{}), - options: Options(responseType: ResponseType.json, sendTimeout: 5000, receiveTimeout: 5000) + options: Options(responseType: ResponseType.json, sendTimeout: 10000, receiveTimeout: 10000) ); return response.data; } @@ -73,7 +73,7 @@ class DeezerAPI { Dio dio = Dio(); Response response = await dio.get( '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; } diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index d30ed53..91235cb 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -33,11 +33,12 @@ class Track { //TODO: Not in DB int diskNumber; + bool explicit; List playbackDetails; 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((art) => art.name).join(', '); 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']], lyrics: Lyrics(id: json['LYRICS_ID'].toString()), 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 toSQL({off = false}) => { @@ -470,15 +472,17 @@ class Lyrics { class Lyric { Duration offset; String text; + String lrcTimestamp; - Lyric({this.offset, this.text}); + Lyric({this.offset, this.text, this.lrcTimestamp}); //JSON factory Lyric.fromPrivateJson(Map json) { if (json['milliseconds'] == null || json['line'] == null) return Lyric(); //Empty lyric return Lyric( offset: Duration(milliseconds: int.parse(json['milliseconds'].toString())), - text: json['line'] + text: json['line'], + lrcTimestamp: json['lrc_timestamp'] ); } diff --git a/lib/api/definitions.g.dart b/lib/api/definitions.g.dart index cbd6ea9..3a50b27 100644 --- a/lib/api/definitions.g.dart +++ b/lib/api/definitions.g.dart @@ -31,6 +31,7 @@ Track _$TrackFromJson(Map json) { : Lyrics.fromJson(json['lyrics'] as Map), favorite: json['favorite'] as bool, diskNumber: json['diskNumber'] as int, + explicit: json['explicit'] as bool, ); } @@ -46,6 +47,7 @@ Map _$TrackToJson(Track instance) => { 'lyrics': instance.lyrics, 'favorite': instance.favorite, 'diskNumber': instance.diskNumber, + 'explicit': instance.explicit, 'playbackDetails': instance.playbackDetails, }; @@ -244,12 +246,14 @@ Lyric _$LyricFromJson(Map json) { ? null : Duration(microseconds: json['offset'] as int), text: json['text'] as String, + lrcTimestamp: json['lrcTimestamp'] as String, ); } Map _$LyricToJson(Lyric instance) => { 'offset': instance.offset?.inMicroseconds, 'text': instance.text, + 'lrcTimestamp': instance.lrcTimestamp, }; QueueSource _$QueueSourceFromJson(Map json) { diff --git a/lib/api/download.dart b/lib/api/download.dart index 506dc27..e33abaa 100644 --- a/lib/api/download.dart +++ b/lib/api/download.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:disk_space/disk_space.dart'; import 'package:ext_storage/ext_storage.dart'; import 'package:flutter/services.dart'; @@ -30,7 +32,7 @@ class DownloadManager { FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; bool _cancelNotifications = true; - bool get stopped => queue.length > 0 && _download == null; + bool stopped = true; Future init() async { //Prepare DB @@ -115,7 +117,7 @@ class DownloadManager { //Update queue, start new download void updateQueue() async { - if (_download == null && queue.length > 0) { + if (_download == null && queue.length > 0 && !stopped) { _download = queue[0].download( onDone: () async { //On download finished @@ -137,10 +139,12 @@ class DownloadManager { updateQueue(); } ).catchError((e, st) async { + if (stopped) return; print('Download error: $e\n$st'); //Catch download errors _download = null; _cancelNotifications = true; + //Cancellation error i guess await _showError(); }); //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 Future _showError() async { AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( @@ -290,18 +310,13 @@ class DownloadManager { return d.path; } - Future addOfflineTrack(Track track, {private = true}) async { + Future addOfflineTrack(Track track, {private = true, forceStart = true}) async { //Paths String path = p.join(_offlinePath, track.id); if (track.playbackDetails == null) { //Get track from API if download info missing 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)); if (!private) { @@ -316,6 +331,12 @@ class DownloadManager { if (settings.downloadQuality == AudioQuality.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); @@ -339,7 +360,7 @@ class DownloadManager { await b.commit(); queue.add(download); - updateQueue(); + if (forceStart) start(); } Future addOfflineAlbum(Album album, {private = true}) async { @@ -353,8 +374,9 @@ class DownloadManager { } //Save all 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 @@ -370,8 +392,9 @@ class DownloadManager { } //Download all 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 Future clearQueue() async { - for (int i=queue.length-1; i>0; i--) { - await removeDownload(queue[i]); + while (queue.length > 0) { + 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; String _cover; + //For canceling + IOSink _outSink; + CancelToken _cancel; + StreamSubscription _progressSub; + int received = 0; int total = 1; 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 { Dio dio = Dio(); //TODO: Check for internet before downloading - if (!this.private) { + if (!this.private && !(this.path.endsWith('.mp3') || this.path.endsWith('.flac'))) { String ext = this.path; //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); - //Get path if public RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]'); //Download path @@ -533,6 +577,9 @@ class Download { //Create filename 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 Map vars = { '%artists%': track.artistString.replaceAll(sanitize, ''), @@ -540,7 +587,8 @@ class Download { '%title%': track.title.replaceAll(sanitize, ''), '%album%': track.album.title.replaceAll(sanitize, ''), '%trackNumber%': track.trackNumber.toString(), - '%0trackNumber%': track.trackNumber.toString().padLeft(2, '0') + '%0trackNumber%': track.trackNumber.toString().padLeft(2, '0'), + '%feats%': feats }; //Replace vars.forEach((key, value) { @@ -553,15 +601,48 @@ class Download { //Download 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.path + '.ENC', - deleteOnError: true, - onReceiveProgress: (rec, total) { - this.received = rec; - this.total = total; - } + options: Options( + responseType: ResponseType.stream, + headers: { + 'Range': 'bytes=$start-' + }, + ), + cancelToken: _cancel ); + //Size + this.total = int.parse(response.headers['Content-Length'][0]) + start; + this.received = start; + //Save + _outSink = downloadFile.openWrite(mode: FileMode.append); + Stream _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; //Decrypt @@ -586,6 +667,28 @@ class Download { //Remove encrypted await File(path + '.ENC').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; onDone(); return; diff --git a/lib/api/player.dart b/lib/api/player.dart index a2b6ac2..0d47cbc 100644 --- a/lib/api/player.dart +++ b/lib/api/player.dart @@ -309,12 +309,13 @@ class AudioPlayerTask extends BackgroundAudioTask { MediaControl.skipToPrevious, if (_player.playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, - //MediaControl.stop + MediaControl.stop ], systemActions: [ MediaAction.seekTo, MediaAction.seekForward, - MediaAction.seekBackward + MediaAction.seekBackward, + //MediaAction.stop ], processingState: _getProcessingState(), playing: _player.playing, diff --git a/lib/ui/downloads_screen.dart b/lib/ui/downloads_screen.dart index 4da5f8b..2d31540 100644 --- a/lib/ui/downloads_screen.dart +++ b/lib/ui/downloads_screen.dart @@ -52,6 +52,7 @@ class DownloadTile extends StatelessWidget { subtitle: Text(subtitle), leading: CachedImage( url: download.track.albumArt.thumb, + width: 48.0, ), trailing: trailing, onTap: () { @@ -102,30 +103,12 @@ class _DownloadsScreenState extends State { title: Text('Downloads'), actions: [ IconButton( - icon: Icon(Icons.delete_sweep), + icon: Icon(downloadManager.stopped ? Icons.play_arrow : Icons.stop), onPressed: () { - 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(); - }, - ) - ], - ); - } - ); + setState(() { + if (downloadManager.stopped) downloadManager.start(); + else downloadManager.stop(); + }); }, ) ], @@ -140,9 +123,41 @@ class _DownloadsScreenState extends State { return Container(width: 0, height: 0,); return Column( - children: List.generate(downloadManager.queue.length, (i) { - return DownloadTile(downloadManager.queue[i], onDelete: () => setState(() => {})); - }) + children: [ + ...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(); + }, + ) + ], + ); + } + ); + }, + ) + ] ); }, ), diff --git a/lib/ui/library.dart b/lib/ui/library.dart index 5cbbc1f..d418af4 100644 --- a/lib/ui/library.dart +++ b/lib/ui/library.dart @@ -56,20 +56,20 @@ class LibraryScreen extends StatelessWidget { body: ListView( children: [ Container(height: 4.0,), - if (downloadManager.stopped) + if (downloadManager.stopped && downloadManager.queue.length > 0) ListTile( title: Text('Downloads'), leading: Icon(Icons.file_download), subtitle: Text('Downloading is currently stopped, click here to resume.'), onTap: () { - downloadManager.updateQueue(); + downloadManager.start(); Navigator.of(context).push(MaterialPageRoute( builder: (context) => DownloadsScreen() )); }, ), //Dirty if to not use columns - if (downloadManager.stopped) + if (downloadManager.stopped && downloadManager.queue.length > 0) Divider(), ListTile( diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index ded1e00..7ca48fe 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -497,7 +497,7 @@ class _GeneralSettingsState extends State { ), Container(height: 8.0), Text( - 'Valid variables are: %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%', + 'Valid variables are: %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%, %feats%', style: TextStyle( fontSize: 12.0, ), diff --git a/lib/ui/tiles.dart b/lib/ui/tiles.dart index 785a44e..7f846cb 100644 --- a/lib/ui/tiles.dart +++ b/lib/ui/tiles.dart @@ -49,6 +49,7 @@ class _TrackTileState extends State { title: Text( widget.track.title, maxLines: 1, + overflow: TextOverflow.clip, style: TextStyle( color: nowPlaying?Theme.of(context).primaryColor:null ), @@ -59,12 +60,23 @@ class _TrackTileState extends State { ), leading: CachedImage( url: widget.track.albumArt.thumb, + width: 48, ), onTap: widget.onTap, onLongPress: widget.onHold, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + if (widget.track.explicit??false) + Padding( + padding: EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + 'E', + style: TextStyle( + color: Colors.red + ), + ), + ), Padding( padding: EdgeInsets.symmetric(horizontal: 2.0), child: Text(widget.track.durationString), @@ -98,6 +110,7 @@ class AlbumTile extends StatelessWidget { ), leading: CachedImage( url: album.art.thumb, + width: 48, ), onTap: onTap, onLongPress: onHold, @@ -172,6 +185,7 @@ class PlaylistTile extends StatelessWidget { ), leading: CachedImage( url: playlist.image.thumb, + width: 48, ), onTap: onTap, onLongPress: onHold,