0.5.0 - Rewritten downloads, many bugfixes
This commit is contained in:
parent
f7cbb09bc1
commit
f2f6b202d1
38 changed files with 5176 additions and 1365 deletions
67
lib/api/cache.dart
Normal file
67
lib/api/cache.dart
Normal file
|
@ -0,0 +1,67 @@
|
|||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
part 'cache.g.dart';
|
||||
|
||||
Cache cache;
|
||||
|
||||
//Cache for miscellaneous things
|
||||
@JsonSerializable()
|
||||
class Cache {
|
||||
|
||||
//ID's of tracks that are in library
|
||||
List<String> libraryTracks = [];
|
||||
|
||||
//Track ID of logged track, to prevent duplicates
|
||||
@JsonKey(ignore: true)
|
||||
String loggedTrackId;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
List<Track> history = [];
|
||||
|
||||
//Cache playlist sort type {id: sort}
|
||||
@JsonKey(defaultValue: {})
|
||||
Map<String, SortType> playlistSort;
|
||||
|
||||
|
||||
Cache({this.libraryTracks});
|
||||
|
||||
//Wrapper to test if track is favorite against cache
|
||||
bool checkTrackFavorite(Track t) {
|
||||
if (t.favorite != null && t.favorite) return true;
|
||||
if (libraryTracks == null || libraryTracks.length == 0) return false;
|
||||
return libraryTracks.contains(t.id);
|
||||
}
|
||||
|
||||
//Save, load
|
||||
static Future<String> getPath() async {
|
||||
return p.join((await getApplicationDocumentsDirectory()).path, 'metacache.json');
|
||||
}
|
||||
|
||||
static Future<Cache> load() async {
|
||||
File file = File(await Cache.getPath());
|
||||
//Doesn't exist, create new
|
||||
if (!(await file.exists())) {
|
||||
Cache c = Cache();
|
||||
await c.save();
|
||||
return c;
|
||||
}
|
||||
return Cache.fromJson(jsonDecode(await file.readAsString()));
|
||||
}
|
||||
|
||||
Future save() async {
|
||||
File file = File(await Cache.getPath());
|
||||
file.writeAsString(jsonEncode(this.toJson()));
|
||||
}
|
||||
|
||||
//JSON
|
||||
factory Cache.fromJson(Map<String, dynamic> json) => _$CacheFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CacheToJson(this);
|
||||
}
|
69
lib/api/cache.g.dart
Normal file
69
lib/api/cache.g.dart
Normal file
|
@ -0,0 +1,69 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cache.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Cache _$CacheFromJson(Map<String, dynamic> json) {
|
||||
return Cache(
|
||||
libraryTracks:
|
||||
(json['libraryTracks'] as List)?.map((e) => e as String)?.toList(),
|
||||
)
|
||||
..history = (json['history'] as List)
|
||||
?.map((e) =>
|
||||
e == null ? null : Track.fromJson(e as Map<String, dynamic>))
|
||||
?.toList() ??
|
||||
[]
|
||||
..playlistSort = (json['playlistSort'] as Map<String, dynamic>)?.map(
|
||||
(k, e) => MapEntry(k, _$enumDecodeNullable(_$SortTypeEnumMap, e)),
|
||||
) ??
|
||||
{};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$CacheToJson(Cache instance) => <String, dynamic>{
|
||||
'libraryTracks': instance.libraryTracks,
|
||||
'history': instance.history,
|
||||
'playlistSort': instance.playlistSort
|
||||
?.map((k, e) => MapEntry(k, _$SortTypeEnumMap[e])),
|
||||
};
|
||||
|
||||
T _$enumDecode<T>(
|
||||
Map<T, dynamic> enumValues,
|
||||
dynamic source, {
|
||||
T unknownValue,
|
||||
}) {
|
||||
if (source == null) {
|
||||
throw ArgumentError('A value must be provided. Supported values: '
|
||||
'${enumValues.values.join(', ')}');
|
||||
}
|
||||
|
||||
final value = enumValues.entries
|
||||
.singleWhere((e) => e.value == source, orElse: () => null)
|
||||
?.key;
|
||||
|
||||
if (value == null && unknownValue == null) {
|
||||
throw ArgumentError('`$source` is not one of the supported values: '
|
||||
'${enumValues.values.join(', ')}');
|
||||
}
|
||||
return value ?? unknownValue;
|
||||
}
|
||||
|
||||
T _$enumDecodeNullable<T>(
|
||||
Map<T, dynamic> enumValues,
|
||||
dynamic source, {
|
||||
T unknownValue,
|
||||
}) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
|
||||
}
|
||||
|
||||
const _$SortTypeEnumMap = {
|
||||
SortType.DEFAULT: 'DEFAULT',
|
||||
SortType.REVERSE: 'REVERSE',
|
||||
SortType.ALPHABETIC: 'ALPHABETIC',
|
||||
SortType.ARTIST: 'ARTIST',
|
||||
};
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:dio/adapter.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
@ -47,6 +49,15 @@ class DeezerAPI {
|
|||
return options;
|
||||
}
|
||||
));
|
||||
|
||||
//Proxy
|
||||
if (settings.proxyAddress != null && settings.proxyAddress != '' && settings.proxyAddress.length > 9) {
|
||||
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
|
||||
client.findProxy = (uri) => "PROXY ${settings.proxyAddress}";
|
||||
client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
|
||||
};
|
||||
}
|
||||
|
||||
//Add cookies
|
||||
List<Cookie> cookies = [Cookie('arl', this.arl)];
|
||||
_cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies);
|
||||
|
@ -82,13 +93,13 @@ class DeezerAPI {
|
|||
//Wrapper so it can be globally awaited
|
||||
Future authorize() async {
|
||||
if (_authorizing == null) {
|
||||
this._authorizing = this._authorize();
|
||||
this._authorizing = this.rawAuthorize();
|
||||
}
|
||||
return _authorizing;
|
||||
}
|
||||
|
||||
//Authorize, bool = success
|
||||
Future<bool> _authorize() async {
|
||||
Future<bool> rawAuthorize({Function onError}) async {
|
||||
try {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.getUserData');
|
||||
if (data['results']['USER']['USER_ID'] == 0) {
|
||||
|
@ -100,7 +111,31 @@ class DeezerAPI {
|
|||
this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
|
||||
return true;
|
||||
}
|
||||
} catch (e) { return false; }
|
||||
} catch (e) {
|
||||
if (onError != null)
|
||||
onError(e);
|
||||
print('Login Error (D): ' + e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//URL/Link parser
|
||||
Future<DeezerLinkResponse> parseLink(String url) async {
|
||||
Uri uri = Uri.parse(url);
|
||||
//https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID
|
||||
if (uri.host == 'www.deezer.com' || uri.host == 'deezer.com') {
|
||||
if (uri.pathSegments.length < 2) return null;
|
||||
DeezerLinkType type = DeezerLinkResponse.typeFromString(uri.pathSegments[uri.pathSegments.length-2]);
|
||||
return DeezerLinkResponse(type: type, id: uri.pathSegments[uri.pathSegments.length-1]);
|
||||
}
|
||||
//Share URL
|
||||
if (uri.host == 'deezer.page.link' || uri.host == 'www.deezer.page.link') {
|
||||
Dio dio = Dio();
|
||||
Response res = await dio.head(url, options: RequestOptions(
|
||||
followRedirects: true
|
||||
));
|
||||
return parseLink('http://deezer.com' + res.realUri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
//Search
|
||||
|
@ -168,19 +203,6 @@ class DeezerAPI {
|
|||
//Get playlist with all tracks
|
||||
Future<Playlist> fullPlaylist(String id) async {
|
||||
return await playlist(id, nb: 100000);
|
||||
|
||||
//OLD WORKAROUND
|
||||
/*
|
||||
Playlist p = await playlist(id, nb: 200);
|
||||
for (int i=200; i<p.trackCount; i++) {
|
||||
//Get another page of tracks
|
||||
List<Track> tracks = await playlistTracksPage(id, i, nb: 200);
|
||||
p.tracks.addAll(tracks);
|
||||
i += 200;
|
||||
continue;
|
||||
}
|
||||
return p;
|
||||
*/
|
||||
}
|
||||
|
||||
//Add track to favorites
|
||||
|
@ -271,7 +293,7 @@ class DeezerAPI {
|
|||
Map data = await callApi('song.getLyrics', params: {
|
||||
'sng_id': trackId
|
||||
});
|
||||
if (data['error'] != null && data['error'].length > 0) return Lyrics().error;
|
||||
if (data['error'] != null && data['error'].length > 0) return Lyrics.error();
|
||||
return Lyrics.fromPrivateJson(data['results']);
|
||||
}
|
||||
|
||||
|
@ -318,7 +340,15 @@ class DeezerAPI {
|
|||
|
||||
//Log song listen to deezer
|
||||
Future logListen(String trackId) async {
|
||||
await callApi('log.listen', params: {'next_media': {'media': {'id': trackId, 'type': 'song'}}});
|
||||
await callApi('log.listen', params: {
|
||||
'params': {
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
'ts_listen': DateTime.now().millisecondsSinceEpoch,
|
||||
'type': 1,
|
||||
'stat': {'seek': 0, 'pause': 0, 'sync': 1},
|
||||
'media': {'id': trackId, 'type': 'song', 'format': 'MP3_128'}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<HomePage> getChannel(String target) async {
|
||||
|
@ -406,5 +436,16 @@ class DeezerAPI {
|
|||
});
|
||||
return data['results']['data'].map<Track>((t) => Track.fromPrivateJson(t)).toList();
|
||||
}
|
||||
|
||||
//Update playlist metadata, status = see createPlaylist
|
||||
Future updatePlaylist(String id, String title, String description, {int status = 1}) async {
|
||||
await callApi('playlist.update', params: {
|
||||
'description': description,
|
||||
'title': title,
|
||||
'playlist_id': int.parse(id),
|
||||
'status': status,
|
||||
'songs': []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import 'package:pointycastle/block/aes_fast.dart';
|
|||
import 'package:pointycastle/block/modes/ecb.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
@ -30,8 +31,6 @@ class Track {
|
|||
bool offline;
|
||||
Lyrics lyrics;
|
||||
bool favorite;
|
||||
|
||||
//TODO: Not in DB
|
||||
int diskNumber;
|
||||
bool explicit;
|
||||
|
||||
|
@ -102,6 +101,10 @@ class Track {
|
|||
artists = jsonDecode(mi.extras['artists']).map<Artist>((j) => Artist.fromJson(j)).toList();
|
||||
}
|
||||
}
|
||||
List<String> playbackDetails;
|
||||
if (mi.extras['playbackDetails'] != null)
|
||||
playbackDetails = jsonDecode(mi.extras['playbackDetails']).map<String>((e) => e.toString()).toList();
|
||||
|
||||
return Track(
|
||||
title: mi.title??mi.displayTitle,
|
||||
artists: artists,
|
||||
|
@ -112,7 +115,7 @@ class Track {
|
|||
thumbUrl: mi.extras['thumb']
|
||||
),
|
||||
duration: mi.duration,
|
||||
playbackDetails: null, // So it gets updated from api
|
||||
playbackDetails: playbackDetails,
|
||||
lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}"))
|
||||
);
|
||||
}
|
||||
|
@ -149,7 +152,9 @@ class Track {
|
|||
'trackNumber': trackNumber,
|
||||
'offline': off?1:0,
|
||||
'lyrics': jsonEncode(lyrics.toJson()),
|
||||
'favorite': (favorite??0)?1:0
|
||||
'favorite': (favorite??0)?1:0,
|
||||
'diskNumber': diskNumber,
|
||||
'explicit': explicit?1:0
|
||||
};
|
||||
factory Track.fromSQL(Map<String, dynamic> data) => Track(
|
||||
id: data['trackId']??data['id'], //If loading from downloads table
|
||||
|
@ -163,7 +168,9 @@ class Track {
|
|||
)),
|
||||
offline: (data['offline'] == 1) ? true:false,
|
||||
lyrics: Lyrics.fromJson(jsonDecode(data['lyrics'])),
|
||||
favorite: (data['favorite'] == 1) ? true:false
|
||||
favorite: (data['favorite'] == 1) ? true:false,
|
||||
diskNumber: data['diskNumber'],
|
||||
explicit: (data['explicit'] == 1) ? true:false
|
||||
);
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
|
@ -186,8 +193,6 @@ class Album {
|
|||
int fans;
|
||||
bool offline; //If the album is offline, or just saved in db as metadata
|
||||
bool library;
|
||||
|
||||
//TODO: Not in DB
|
||||
AlbumType type;
|
||||
String releaseDate;
|
||||
|
||||
|
@ -224,7 +229,9 @@ class Album {
|
|||
'art': art.full,
|
||||
'fans': fans,
|
||||
'offline': off?1:0,
|
||||
'library': (library??false)?1:0
|
||||
'library': (library??false)?1:0,
|
||||
'type': AlbumType.values.indexOf(type),
|
||||
'releaseDate': releaseDate
|
||||
};
|
||||
factory Album.fromSQL(Map<String, dynamic> data) => Album(
|
||||
id: data['id'],
|
||||
|
@ -238,7 +245,9 @@ class Album {
|
|||
art: ImageDetails(fullUrl: data['art']),
|
||||
fans: data['fans'],
|
||||
offline: (data['offline'] == 1) ? true:false,
|
||||
library: (data['library'] == 1) ? true:false
|
||||
library: (data['library'] == 1) ? true:false,
|
||||
type: AlbumType.values[data['type']],
|
||||
releaseDate: data['releaseDate']
|
||||
);
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> json) => _$AlbumFromJson(json);
|
||||
|
@ -256,8 +265,6 @@ class Artist {
|
|||
int fans;
|
||||
bool offline;
|
||||
bool library;
|
||||
|
||||
//TODO: NOT IN DB
|
||||
bool radio;
|
||||
|
||||
Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library, this.radio});
|
||||
|
@ -296,7 +303,8 @@ class Artist {
|
|||
'fans': fans,
|
||||
'albumCount': this.albumCount??(this.albums??[]).length,
|
||||
'offline': off?1:0,
|
||||
'library': (library??false)?1:0
|
||||
'library': (library??false)?1:0,
|
||||
'radio': radio?1:0
|
||||
};
|
||||
factory Artist.fromSQL(Map<String, dynamic> data) => Artist(
|
||||
id: data['id'],
|
||||
|
@ -311,7 +319,8 @@ class Artist {
|
|||
picture: ImageDetails(fullUrl: data['picture']),
|
||||
fans: data['fans'],
|
||||
offline: (data['offline'] == 1)?true:false,
|
||||
library: (data['library'] == 1)?true:false
|
||||
library: (data['library'] == 1)?true:false,
|
||||
radio: (data['radio'] == 1)?true:false
|
||||
);
|
||||
|
||||
factory Artist.fromJson(Map<String, dynamic> json) => _$ArtistFromJson(json);
|
||||
|
@ -456,12 +465,12 @@ class Lyrics {
|
|||
|
||||
Lyrics({this.id, this.writers, this.lyrics});
|
||||
|
||||
Lyrics get error => Lyrics(
|
||||
id: id,
|
||||
writers: writers,
|
||||
static error() => Lyrics(
|
||||
id: null,
|
||||
writers: null,
|
||||
lyrics: [Lyric(
|
||||
offset: Duration(milliseconds: 0),
|
||||
text: 'Error loading lyrics!'
|
||||
text: 'Lyrics unavailable, empty or failed to load!'.i18n
|
||||
)]
|
||||
);
|
||||
|
||||
|
@ -470,7 +479,7 @@ class Lyrics {
|
|||
Lyrics l = Lyrics(
|
||||
id: json['LYRICS_ID'],
|
||||
writers: json['LYRICS_WRITERS'],
|
||||
lyrics: json['LYRICS_SYNC_JSON'].map<Lyric>((l) => Lyric.fromPrivateJson(l)).toList()
|
||||
lyrics: (json['LYRICS_SYNC_JSON']??[]).map<Lyric>((l) => Lyric.fromPrivateJson(l)).toList()
|
||||
);
|
||||
//Clean empty lyrics
|
||||
l.lyrics.removeWhere((l) => l.offset == null);
|
||||
|
@ -723,4 +732,28 @@ enum RepeatType {
|
|||
NONE,
|
||||
LIST,
|
||||
TRACK
|
||||
}
|
||||
|
||||
enum DeezerLinkType {
|
||||
TRACK,
|
||||
ALBUM,
|
||||
ARTIST,
|
||||
PLAYLIST
|
||||
}
|
||||
|
||||
class DeezerLinkResponse {
|
||||
DeezerLinkType type;
|
||||
String id;
|
||||
|
||||
DeezerLinkResponse({this.type, this.id});
|
||||
|
||||
//String to DeezerLinkType
|
||||
static typeFromString(String t) {
|
||||
t = t.toLowerCase().trim();
|
||||
if (t == 'album') return DeezerLinkType.ALBUM;
|
||||
if (t == 'artist') return DeezerLinkType.ARTIST;
|
||||
if (t == 'playlist') return DeezerLinkType.PLAYLIST;
|
||||
if (t == 'track') return DeezerLinkType.TRACK;
|
||||
return null;
|
||||
}
|
||||
}
|
804
lib/api/download-out.dart
Normal file
804
lib/api/download-out.dart
Normal file
|
@ -0,0 +1,804 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:disk_space/disk_space.dart';
|
||||
import 'package:ext_storage/ext_storage.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:random_string/random_string.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'dart:async';
|
||||
import 'deezer.dart';
|
||||
import '../settings.dart';
|
||||
import 'definitions.dart';
|
||||
import '../ui/cached_image.dart';
|
||||
|
||||
DownloadManager downloadManager = DownloadManager();
|
||||
MethodChannel platformChannel = const MethodChannel('f.f.freezer/native');
|
||||
|
||||
class DownloadManager {
|
||||
|
||||
Database db;
|
||||
List<Download> queue = [];
|
||||
String _offlinePath;
|
||||
Future _download;
|
||||
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;
|
||||
bool _cancelNotifications = true;
|
||||
|
||||
bool stopped = true;
|
||||
|
||||
Future init() async {
|
||||
//Prepare DB
|
||||
String dir = await getDatabasesPath();
|
||||
String path = p.join(dir, 'offline.db');
|
||||
db = await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: (Database db, int version) async {
|
||||
Batch b = db.batch();
|
||||
//Create tables
|
||||
b.execute(""" CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, url TEXT, private INTEGER, state INTEGER, trackId TEXT)""");
|
||||
b.execute("""CREATE TABLE tracks (
|
||||
id TEXT PRIMARY KEY, title TEXT, album TEXT, artists TEXT, duration INTEGER, albumArt TEXT, trackNumber INTEGER, offline INTEGER, lyrics TEXT, favorite INTEGER)""");
|
||||
b.execute("""CREATE TABLE albums (
|
||||
id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER)""");
|
||||
b.execute("""CREATE TABLE artists (
|
||||
id TEXT PRIMARY KEY, name TEXT, albums TEXT, topTracks TEXT, picture TEXT, fans INTEGER, albumCount INTEGER, offline INTEGER, library INTEGER)""");
|
||||
b.execute("""CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY, title TEXT, tracks TEXT, image TEXT, duration INTEGER, userId TEXT, userName TEXT, fans INTEGER, library INTEGER, description TEXT)""");
|
||||
await b.commit();
|
||||
}
|
||||
);
|
||||
//Prepare folders (/sdcard/Android/data/freezer/data/)
|
||||
_offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
|
||||
await Directory(_offlinePath).create(recursive: true);
|
||||
|
||||
//Notifications
|
||||
await _prepareNotifications();
|
||||
|
||||
//Restore
|
||||
List<Map> downloads = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 0");
|
||||
downloads.forEach((download) => queue.add(Download.fromSQL(download, parseTrack: true)));
|
||||
}
|
||||
|
||||
//Initialize flutter local notification plugin
|
||||
Future _prepareNotifications() async {
|
||||
flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
AndroidInitializationSettings androidInitializationSettings = AndroidInitializationSettings('@drawable/ic_logo');
|
||||
InitializationSettings initializationSettings = InitializationSettings(androidInitializationSettings, null);
|
||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||
}
|
||||
|
||||
//Show download progress notification, if now/total = null, show intermediate
|
||||
Future _startProgressNotification() async {
|
||||
_cancelNotifications = false;
|
||||
Timer.periodic(Duration(milliseconds: 500), (timer) async {
|
||||
//Cancel notifications
|
||||
if (_cancelNotifications) {
|
||||
flutterLocalNotificationsPlugin.cancel(10);
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
//Not downloading
|
||||
if (this.queue.length <= 0) return;
|
||||
Download d = queue[0];
|
||||
//Prepare and show notification
|
||||
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
|
||||
'download', 'Download', 'Download',
|
||||
importance: Importance.Default,
|
||||
priority: Priority.Default,
|
||||
showProgress: true,
|
||||
maxProgress: d.total??1,
|
||||
progress: d.received??1,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
autoCancel: true,
|
||||
//ongoing: true, //Allow dismissing
|
||||
indeterminate: (d.total == null || d.total == d.received),
|
||||
onlyAlertOnce: true
|
||||
);
|
||||
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
|
||||
await downloadManager.flutterLocalNotificationsPlugin.show(
|
||||
10,
|
||||
'Downloading: ${d.track.title}',
|
||||
(d.state == DownloadState.POST) ? 'Post processing...' : '${filesize(d.received)} / ${filesize(d.total)} (${queue.length} in queue)',
|
||||
notificationDetails
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//Update queue, start new download
|
||||
void updateQueue() async {
|
||||
if (_download == null && queue.length > 0 && !stopped) {
|
||||
_download = queue[0].download(
|
||||
onDone: () async {
|
||||
//On download finished
|
||||
await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]);
|
||||
queue.removeAt(0);
|
||||
_download = null;
|
||||
//Remove notification if no more downloads
|
||||
if (queue.length == 0) {
|
||||
_cancelNotifications = true;
|
||||
}
|
||||
updateQueue();
|
||||
}
|
||||
).catchError((e, st) async {
|
||||
if (stopped) return;
|
||||
_cancelNotifications = true;
|
||||
|
||||
//Deezer error - track is unavailable
|
||||
if (queue[0].state == DownloadState.DEEZER_ERROR) {
|
||||
await db.rawUpdate('UPDATE downloads SET state = 4 WHERE trackId = ?', [queue[0].track.id]);
|
||||
queue.removeAt(0);
|
||||
_cancelNotifications = false;
|
||||
_download = null;
|
||||
updateQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
//Clean
|
||||
_download = null;
|
||||
stopped = true;
|
||||
print('Download error: $e\n$st');
|
||||
|
||||
queue[0].state = DownloadState.NONE;
|
||||
//Shift to end
|
||||
queue.add(queue[0]);
|
||||
queue.removeAt(0);
|
||||
//Show error
|
||||
await _showError();
|
||||
});
|
||||
//Show download progress notifications
|
||||
if (_cancelNotifications == null || _cancelNotifications) _startProgressNotification();
|
||||
}
|
||||
}
|
||||
|
||||
//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(
|
||||
'downloadError', 'Download Error', 'Download Error'
|
||||
);
|
||||
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
|
||||
flutterLocalNotificationsPlugin.show(
|
||||
11, 'Error while downloading!', 'Please restart downloads in the library', notificationDetails
|
||||
);
|
||||
}
|
||||
|
||||
//Returns all offline tracks
|
||||
Future<List<Track>> allOfflineTracks() async {
|
||||
List data = await db.query('tracks', where: 'offline == 1');
|
||||
List<Track> tracks = [];
|
||||
//Load track data
|
||||
for (var t in data) {
|
||||
tracks.add(await getTrack(t['id']));
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
//Get all offline playlists
|
||||
Future<List<Playlist>> getOfflinePlaylists() async {
|
||||
List data = await db.query('playlists');
|
||||
List<Playlist> playlists = [];
|
||||
//Load playlists
|
||||
for (var p in data) {
|
||||
playlists.add(await getPlaylist(p['id']));
|
||||
}
|
||||
return playlists;
|
||||
}
|
||||
|
||||
//Get playlist metadata with tracks
|
||||
Future<Playlist> getPlaylist(String id) async {
|
||||
if (id == null) return null;
|
||||
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
|
||||
if (data.length == 0) return null;
|
||||
//Load playlist tracks
|
||||
Playlist p = Playlist.fromSQL(data[0]);
|
||||
for (int i=0; i<p.tracks.length; i++) {
|
||||
p.tracks[i] = await getTrack(p.tracks[i].id);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
//Gets favorites
|
||||
Future<Playlist> getFavorites() async {
|
||||
return await getPlaylist('FAVORITES');
|
||||
}
|
||||
|
||||
Future<List<Album>> getOfflineAlbums({List albumsData}) async {
|
||||
//Load albums
|
||||
if (albumsData == null) {
|
||||
albumsData = await db.query('albums', where: 'offline == 1');
|
||||
}
|
||||
List<Album> albums = albumsData.map((alb) => Album.fromSQL(alb)).toList();
|
||||
for(int i=0; i<albums.length; i++) {
|
||||
albums[i].library = true;
|
||||
//Load tracks
|
||||
for(int j=0; j<albums[i].tracks.length; j++) {
|
||||
albums[i].tracks[j] = await getTrack(albums[i].tracks[j].id, album: albums[i]);
|
||||
}
|
||||
//Load artists
|
||||
List artistsData = await db.rawQuery('SELECT * FROM artists WHERE id IN (${albumsData[i]['artists']})');
|
||||
albums[i].artists = artistsData.map<Artist>((a) => Artist.fromSQL(a)).toList();
|
||||
}
|
||||
return albums;
|
||||
}
|
||||
|
||||
//Get track with metadata from db
|
||||
Future<Track> getTrack(String id, {Album album, List<Artist> artists}) async {
|
||||
List tracks = await db.query('tracks', where: 'id == ?', whereArgs: [id]);
|
||||
if (tracks.length == 0) return null;
|
||||
Track t = Track.fromSQL(tracks[0]);
|
||||
//Load album from DB
|
||||
t.album = album ?? Album.fromSQL((await db.query('albums', where: 'id == ?', whereArgs: [t.album.id]))[0]);
|
||||
if (artists != null) {
|
||||
t.artists = artists;
|
||||
return t;
|
||||
}
|
||||
//Load artists from DB
|
||||
for (int i=0; i<t.artists.length; i++) {
|
||||
t.artists[i] = Artist.fromSQL(
|
||||
(await db.query('artists', where: 'id == ?', whereArgs: [t.artists[i].id]))[0]);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
Future removeOfflineTrack(String id) async {
|
||||
//Check if track present in albums
|
||||
List counter = await db.rawQuery('SELECT COUNT(*) FROM albums WHERE tracks LIKE "%$id%"');
|
||||
if (counter[0]['COUNT(*)'] > 0) return;
|
||||
//and in playlists
|
||||
counter = await db.rawQuery('SELECT COUNT(*) FROM playlists WHERE tracks LIKE "%$id%"');
|
||||
if (counter[0]['COUNT(*)'] > 0) return;
|
||||
//Remove file
|
||||
List download = await db.query('downloads', where: 'trackId == ?', whereArgs: [id]);
|
||||
await File(download[0]['path']).delete();
|
||||
//Delete from db
|
||||
await db.delete('tracks', where: 'id == ?', whereArgs: [id]);
|
||||
await db.delete('downloads', where: 'trackId == ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
//Delete offline album
|
||||
Future removeOfflineAlbum(String id) async {
|
||||
List data = await db.rawQuery('SELECT * FROM albums WHERE id == ? AND offline == 1', [id]);
|
||||
if (data.length == 0) return;
|
||||
Map<String, dynamic> album = Map.from(data[0]); //make writable
|
||||
//Remove DB
|
||||
album['offline'] = 0;
|
||||
await db.update('albums', album, where: 'id == ?', whereArgs: [id]);
|
||||
//Get track ids
|
||||
List<String> tracks = album['tracks'].split(',');
|
||||
for (String t in tracks) {
|
||||
//Remove tracks
|
||||
await removeOfflineTrack(t);
|
||||
}
|
||||
}
|
||||
|
||||
Future removeOfflinePlaylist(String id) async {
|
||||
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
|
||||
if (data.length == 0) return;
|
||||
Playlist p = Playlist.fromSQL(data[0]);
|
||||
//Remove db
|
||||
await db.delete('playlists', where: 'id == ?', whereArgs: [id]);
|
||||
//Remove tracks
|
||||
for(Track t in p.tracks) {
|
||||
await removeOfflineTrack(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
//Get path to offline track
|
||||
Future<String> getOfflineTrackPath(String id) async {
|
||||
List<Map> tracks = await db.rawQuery('SELECT path FROM downloads WHERE state == 1 AND trackId == ?', [id]);
|
||||
if (tracks.length < 1) {
|
||||
return null;
|
||||
}
|
||||
Download d = Download.fromSQL(tracks[0]);
|
||||
return d.path;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (!private) {
|
||||
//Check permissions
|
||||
if (!(await Permission.storage.request().isGranted)) {
|
||||
return;
|
||||
}
|
||||
//If saving to external
|
||||
//Save just extension to path, will be generated before download
|
||||
path = 'mp3';
|
||||
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, private: private);
|
||||
//Database
|
||||
Batch b = db.batch();
|
||||
b.insert('downloads', download.toSQL());
|
||||
b.insert('tracks', track.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
|
||||
if (private) {
|
||||
//Duplicate check
|
||||
List<Map> duplicate = await db.rawQuery('SELECT * FROM downloads WHERE trackId == ?', [track.id]);
|
||||
if (duplicate.length != 0) return;
|
||||
//Save art
|
||||
//await imagesDatabase.getImage(track.albumArt.full);
|
||||
imagesDatabase.saveImage(track.albumArt.full);
|
||||
//Save to db
|
||||
b.insert('tracks', track.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
b.insert('albums', track.album.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
track.artists.forEach((art) => b.insert('artists', art.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore));
|
||||
}
|
||||
await b.commit();
|
||||
|
||||
queue.add(download);
|
||||
if (forceStart) start();
|
||||
}
|
||||
|
||||
Future addOfflineAlbum(Album album, {private = true}) async {
|
||||
//Get full album from API if tracks are missing
|
||||
if (album.tracks == null || album.tracks.length == 0) {
|
||||
album = await deezerAPI.album(album.id);
|
||||
}
|
||||
//Update album in database
|
||||
if (private) {
|
||||
await db.insert('albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
//Save all tracks
|
||||
for (Track track in album.tracks) {
|
||||
await addOfflineTrack(track, private: private, forceStart: false);
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
//Add offline playlist, can be also used as update
|
||||
Future addOfflinePlaylist(Playlist playlist, {private = true}) async {
|
||||
//Load full playlist if missing tracks
|
||||
if (playlist.tracks == null || playlist.tracks.length != playlist.trackCount) {
|
||||
playlist = await deezerAPI.fullPlaylist(playlist.id);
|
||||
}
|
||||
playlist.library = true;
|
||||
//To DB
|
||||
if (private) {
|
||||
await db.insert('playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
//Download all tracks
|
||||
for (Track t in playlist.tracks) {
|
||||
await addOfflineTrack(t, private: private, forceStart: false);
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
|
||||
Future checkOffline({Album album, Track track, Playlist playlist}) async {
|
||||
//Check if album/track (TODO: Artist, playlist) is offline
|
||||
if (track != null) {
|
||||
List res = await db.query('tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]);
|
||||
if (res.length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (album != null) {
|
||||
List res = await db.query('albums', where: 'id == ? AND offline == 1', whereArgs: [album.id]);
|
||||
if (res.length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (playlist != null && playlist.id != null) {
|
||||
List res = await db.query('playlists', where: 'id == ?', whereArgs: [playlist.id]);
|
||||
if (res.length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//Offline search
|
||||
Future<SearchResults> search(String query) async {
|
||||
SearchResults results = SearchResults(
|
||||
tracks: [],
|
||||
albums: [],
|
||||
artists: [],
|
||||
playlists: []
|
||||
);
|
||||
//Tracks
|
||||
List tracksData = await db.rawQuery('SELECT * FROM tracks WHERE offline == 1 AND title like "%$query%"');
|
||||
for (Map trackData in tracksData) {
|
||||
results.tracks.add(await getTrack(trackData['id']));
|
||||
}
|
||||
//Albums
|
||||
List albumsData = await db.rawQuery('SELECT * FROM albums WHERE offline == 1 AND title like "%$query%"');
|
||||
results.albums = await getOfflineAlbums(albumsData: albumsData);
|
||||
//Artists
|
||||
//TODO: offline artists
|
||||
//Playlists
|
||||
List playlists = await db.rawQuery('SELECT * FROM playlists WHERE title like "%$query%"');
|
||||
for (Map playlist in playlists) {
|
||||
results.playlists.add(await getPlaylist(playlist['id']));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<List<Download>> getFinishedDownloads() async {
|
||||
//Fetch from db
|
||||
List<Map> data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1 OR downloads.state > 3");
|
||||
List<Download> downloads = data.map<Download>((d) => Download.fromSQL(d, parseTrack: true)).toList();
|
||||
return downloads;
|
||||
}
|
||||
|
||||
//Get stats for library screen
|
||||
Future<List<String>> getStats() async {
|
||||
//Get offline counts
|
||||
int trackCount = (await db.rawQuery('SELECT COUNT(*) FROM tracks WHERE offline == 1'))[0]['COUNT(*)'];
|
||||
int albumCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
|
||||
int playlistCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
|
||||
//Free space
|
||||
double diskSpace = await DiskSpace.getFreeDiskSpace;
|
||||
|
||||
//Used space
|
||||
List<FileSystemEntity> offlineStat = await Directory(_offlinePath).list().toList();
|
||||
int offlineSize = 0;
|
||||
for (var fs in offlineStat) {
|
||||
offlineSize += (await fs.stat()).size;
|
||||
}
|
||||
|
||||
//Return as a list, maybe refactor in future if feature stays
|
||||
return ([
|
||||
trackCount.toString(),
|
||||
albumCount.toString(),
|
||||
playlistCount.toString(),
|
||||
filesize(offlineSize),
|
||||
filesize((diskSpace * 1000000).floor())
|
||||
]);
|
||||
}
|
||||
|
||||
//Delete download from db
|
||||
Future removeDownload(Download download) async {
|
||||
await db.delete('downloads', where: 'trackId == ?', whereArgs: [download.track.id]);
|
||||
queue.removeWhere((d) => d.track.id == download.track.id);
|
||||
//TODO: remove files for downloaded
|
||||
}
|
||||
|
||||
//Delete queue
|
||||
Future clearQueue() async {
|
||||
while (queue.length > 0) {
|
||||
if (queue.length == 1) {
|
||||
if (_download != null) break;
|
||||
await removeDownload(queue[0]);
|
||||
return;
|
||||
}
|
||||
await removeDownload(queue[1]);
|
||||
}
|
||||
}
|
||||
|
||||
//Remove non-private downloads
|
||||
Future cleanDownloadHistory() async {
|
||||
await db.delete('downloads', where: 'private == 0');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Download {
|
||||
Track track;
|
||||
String path;
|
||||
String url;
|
||||
bool private;
|
||||
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
|
||||
|
||||
Map rawTrackPublic = {};
|
||||
Map rawAlbumPublic = {};
|
||||
if (!this.private && !(this.path.endsWith('.mp3') || this.path.endsWith('.flac'))) {
|
||||
String ext = this.path;
|
||||
//Get track details
|
||||
Map _rawTrackData = await deezerAPI.callApi('song.getListData', params: {'sng_ids': [track.id]});
|
||||
Map rawTrack = _rawTrackData['results']['data'][0];
|
||||
this.track = Track.fromPrivateJson(rawTrack);
|
||||
//RAW Public API call (for genre and other tags)
|
||||
try {rawTrackPublic = await deezerAPI.callPublicApi('track/${this.track.id}');} catch (e) {rawTrackPublic = {};}
|
||||
try {rawAlbumPublic = await deezerAPI.callPublicApi('album/${this.track.album.id}');} catch (e) {rawAlbumPublic = {};}
|
||||
|
||||
//Global block check
|
||||
if (rawTrackPublic['available_countries'] != null && rawTrackPublic['available_countries'].length == 0) {
|
||||
this.state = DownloadState.DEEZER_ERROR;
|
||||
throw Exception('Download error - not on Deezer');
|
||||
}
|
||||
|
||||
//Get path if public
|
||||
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
|
||||
//Download path
|
||||
this.path = settings.downloadPath ?? (await ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC));
|
||||
if (settings.artistFolder)
|
||||
this.path = p.join(this.path, track.artists[0].name.replaceAll(sanitize, ''));
|
||||
if (settings.albumFolder) {
|
||||
String folderName = track.album.title.replaceAll(sanitize, '');
|
||||
//Add disk number
|
||||
if (settings.albumDiscFolder) folderName += ' - Disk ${track.diskNumber}';
|
||||
|
||||
this.path = p.join(this.path, folderName);
|
||||
}
|
||||
//Make dirs
|
||||
await Directory(this.path).create(recursive: true);
|
||||
|
||||
//Grab cover
|
||||
_cover = p.join(this.path, 'cover.jpg');
|
||||
if (!settings.albumFolder) _cover = p.join(this.path, randomAlpha(12) + '_cover.jpg');
|
||||
|
||||
if (!await File(_cover).exists()) {
|
||||
try {
|
||||
await dio.download(
|
||||
this.track.albumArt.full,
|
||||
_cover,
|
||||
);
|
||||
} catch (e) {print('Error downloading cover');}
|
||||
}
|
||||
|
||||
//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<String, String> vars = {
|
||||
'%artists%': track.artistString.replaceAll(sanitize, ''),
|
||||
'%artist%': track.artists[0].name.replaceAll(sanitize, ''),
|
||||
'%title%': track.title.replaceAll(sanitize, ''),
|
||||
'%album%': track.album.title.replaceAll(sanitize, ''),
|
||||
'%trackNumber%': track.trackNumber.toString(),
|
||||
'%0trackNumber%': track.trackNumber.toString().padLeft(2, '0'),
|
||||
'%feats%': feats
|
||||
};
|
||||
//Replace
|
||||
vars.forEach((key, value) {
|
||||
_filename = _filename.replaceAll(key, value);
|
||||
});
|
||||
_filename += '.$ext';
|
||||
|
||||
this.path = p.join(this.path, _filename);
|
||||
}
|
||||
|
||||
//Check if file exists
|
||||
if (await File(this.path).exists() && !settings.overwriteDownload) {
|
||||
this.state = DownloadState.DONE;
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
//Download
|
||||
this.state = DownloadState.DOWNLOADING;
|
||||
|
||||
//Quality fallback
|
||||
if (this.url == null)
|
||||
await _fallback();
|
||||
|
||||
//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 doesn't exist
|
||||
await downloadFile.create(recursive: true);
|
||||
}
|
||||
|
||||
//Download
|
||||
_cancel = CancelToken();
|
||||
Response response;
|
||||
try {
|
||||
response = await dio.get(
|
||||
this.url,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: {
|
||||
'Range': 'bytes=$start-'
|
||||
},
|
||||
),
|
||||
cancelToken: _cancel
|
||||
);
|
||||
} on DioError catch (e) {
|
||||
//Deezer fetch error
|
||||
if (e.response.statusCode == 403 || e.response.statusCode == 404) {
|
||||
this.state = DownloadState.DEEZER_ERROR;
|
||||
}
|
||||
throw Exception('Download error - Deezer blocked.');
|
||||
}
|
||||
|
||||
//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;
|
||||
//Decrypt
|
||||
await platformChannel.invokeMethod('decryptTrack', {'id': track.id, 'path': path});
|
||||
//Tag
|
||||
if (!private) {
|
||||
//Tag track in native
|
||||
String year;
|
||||
if (rawTrackPublic['release_date'] != null && rawTrackPublic['release_date'].length >= 4)
|
||||
year = rawTrackPublic['release_date'].substring(0, 4);
|
||||
|
||||
await platformChannel.invokeMethod('tagTrack', {
|
||||
'path': path,
|
||||
'title': track.title,
|
||||
'album': track.album.title,
|
||||
'artists': track.artistString,
|
||||
'artist': track.artists[0].name,
|
||||
'cover': _cover,
|
||||
'trackNumber': track.trackNumber,
|
||||
'diskNumber': track.diskNumber,
|
||||
'genres': ((rawAlbumPublic['genres']??{})['data']??[]).map((g) => g['name']).toList(),
|
||||
'year': year,
|
||||
'bpm': rawTrackPublic['bpm'],
|
||||
'explicit': (track.explicit??false) ? "1":"0",
|
||||
'label': rawAlbumPublic['label'],
|
||||
'albumTracks': rawAlbumPublic['nb_tracks'],
|
||||
'date': rawTrackPublic['release_date'],
|
||||
'albumArtist': (rawAlbumPublic['artist']??{})['name']
|
||||
});
|
||||
//Rescan android library
|
||||
await platformChannel.invokeMethod('rescanLibrary', {
|
||||
'path': path
|
||||
});
|
||||
}
|
||||
//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;
|
||||
}
|
||||
|
||||
Future _fallback({fallback}) async {
|
||||
//Get quality
|
||||
AudioQuality quality = private ? settings.offlineQuality : settings.downloadQuality;
|
||||
if (fallback == AudioQuality.MP3_320) quality = AudioQuality.MP3_128;
|
||||
if (fallback == AudioQuality.FLAC) {
|
||||
quality = AudioQuality.MP3_320;
|
||||
if (this.path.toLowerCase().endsWith('flac'))
|
||||
this.path = this.path.substring(0, this.path.length - 4) + 'mp3';
|
||||
}
|
||||
|
||||
//No more fallback
|
||||
if (quality == AudioQuality.MP3_128) {
|
||||
url = track.getUrl(settings.getQualityInt(quality));
|
||||
return;
|
||||
}
|
||||
|
||||
//Check
|
||||
int q = settings.getQualityInt(quality);
|
||||
try {
|
||||
Response res = await Dio().head(track.getUrl(q));
|
||||
if (res.statusCode == 200 || res.statusCode == 206) {
|
||||
this.url = track.getUrl(q);
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
//Fallback
|
||||
return _fallback(fallback: quality);
|
||||
}
|
||||
|
||||
//JSON
|
||||
Map<String, dynamic> toSQL() => {
|
||||
'trackId': track.id,
|
||||
'path': path,
|
||||
'url': url,
|
||||
'state': state.index,
|
||||
'private': private?1:0
|
||||
};
|
||||
factory Download.fromSQL(Map<String, dynamic> data, {parseTrack = false}) => Download(
|
||||
track: parseTrack?Track.fromSQL(data):Track(id: data['trackId']),
|
||||
path: data['path'],
|
||||
url: data['url'],
|
||||
state: DownloadState.values[data['state']],
|
||||
private: data['private'] == 1
|
||||
);
|
||||
}
|
||||
|
||||
enum DownloadState {
|
||||
NONE,
|
||||
DONE,
|
||||
DOWNLOADING,
|
||||
POST,
|
||||
DEEZER_ERROR,
|
||||
ERROR
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,9 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/ui/android_auto.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
@ -21,6 +24,7 @@ PlayerHelper playerHelper = PlayerHelper();
|
|||
class PlayerHelper {
|
||||
|
||||
StreamSubscription _customEventSubscription;
|
||||
StreamSubscription _mediaItemSubscription;
|
||||
StreamSubscription _playbackStateStreamSubscription;
|
||||
QueueSource queueSource;
|
||||
LoopMode repeatType = LoopMode.off;
|
||||
|
@ -65,9 +69,26 @@ class PlayerHelper {
|
|||
//Log song (if allowed)
|
||||
if (event == null) return;
|
||||
if (event.processingState == AudioProcessingState.ready && event.playing) {
|
||||
if (settings.logListen) deezerAPI.logListen(AudioService.currentMediaItem.id);
|
||||
if (settings.logListen) {
|
||||
//Check if duplicate
|
||||
if (cache.loggedTrackId == AudioService.currentMediaItem.id) return;
|
||||
cache.loggedTrackId = AudioService.currentMediaItem.id;
|
||||
deezerAPI.logListen(AudioService.currentMediaItem.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
_mediaItemSubscription = AudioService.currentMediaItemStream.listen((event) {
|
||||
//Save queue
|
||||
AudioService.customAction('saveQueue');
|
||||
|
||||
//Add to history
|
||||
if (event == null) return;
|
||||
if (cache.history == null) cache.history = [];
|
||||
if (cache.history.length > 0 && cache.history.last.id == event.id) return;
|
||||
cache.history.add(Track.fromMediaItem(event));
|
||||
cache.save();
|
||||
});
|
||||
|
||||
//Start audio_service
|
||||
startService();
|
||||
}
|
||||
|
@ -79,7 +100,7 @@ class PlayerHelper {
|
|||
androidEnableQueue: true,
|
||||
androidStopForegroundOnPause: false,
|
||||
androidNotificationOngoing: false,
|
||||
androidNotificationClickStartsActivity: true,
|
||||
androidNotificationClickStartsActivity: false,
|
||||
androidNotificationChannelDescription: 'Freezer',
|
||||
androidNotificationChannelName: 'Freezer',
|
||||
androidNotificationIcon: 'drawable/ic_logo',
|
||||
|
@ -110,6 +131,7 @@ class PlayerHelper {
|
|||
Future onExit() async {
|
||||
_customEventSubscription.cancel();
|
||||
_playbackStateStreamSubscription.cancel();
|
||||
_mediaItemSubscription.cancel();
|
||||
}
|
||||
|
||||
//Replace queue, play specified track id
|
||||
|
@ -256,6 +278,13 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
});
|
||||
//Update state on all clients on change
|
||||
_eventSub = _player.playbackEventStream.listen((event) {
|
||||
//Quality string
|
||||
if (_queueIndex != -1 && _queueIndex < _queue.length) {
|
||||
Map extras = mediaItem.extras;
|
||||
extras['qualityString'] = event.qualityString??'';
|
||||
_queue[_queueIndex] = mediaItem.copyWith(extras: extras);
|
||||
}
|
||||
//Update
|
||||
_broadcastState();
|
||||
});
|
||||
_player.processingStateStream.listen((state) {
|
||||
|
@ -296,6 +325,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
|
||||
//Skip in player
|
||||
await _player.seek(Duration.zero, index: newIndex);
|
||||
_queueIndex = newIndex;
|
||||
_skipState = null;
|
||||
onPlay();
|
||||
}
|
||||
|
@ -327,6 +357,40 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
@override
|
||||
Future<void> onSeekBackward(bool begin) async => _seekContinuously(begin, -1);
|
||||
|
||||
@override
|
||||
Future<void> onSkipToNext() async {
|
||||
//Shuffle
|
||||
if (_player.shuffleModeEnabled??false) {
|
||||
int newIndex = Random().nextInt(_queue.length)-1;
|
||||
//Update state
|
||||
_skipState = newIndex > _queueIndex
|
||||
? AudioProcessingState.skippingToNext
|
||||
: AudioProcessingState.skippingToPrevious;
|
||||
|
||||
_queueIndex = newIndex;
|
||||
await _player.seek(Duration.zero, index: _queueIndex);
|
||||
_skipState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
//Update buffering state
|
||||
_skipState = AudioProcessingState.skippingToNext;
|
||||
_queueIndex++;
|
||||
await _player.seekToNext();
|
||||
_skipState = null;
|
||||
await _broadcastState();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onSkipToPrevious() async {
|
||||
if (_queueIndex == 0) return;
|
||||
//Update buffering state
|
||||
_skipState = AudioProcessingState.skippingToPrevious;
|
||||
_queueIndex--;
|
||||
await _player.seekToPrevious();
|
||||
_skipState = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> onLoadChildren(String parentMediaId) async {
|
||||
AudioServiceBackground.sendCustomEvent({
|
||||
|
@ -417,12 +481,16 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
this._queue = q;
|
||||
AudioServiceBackground.setQueue(_queue);
|
||||
//Load
|
||||
_queueIndex = 0;
|
||||
await _loadQueue();
|
||||
await _player.seek(Duration.zero, index: 0);
|
||||
//await _player.seek(Duration.zero, index: 0);
|
||||
}
|
||||
|
||||
//Load queue to just_audio
|
||||
Future _loadQueue() async {
|
||||
//Don't reset queue index by starting player
|
||||
int qi = _queueIndex;
|
||||
|
||||
List<AudioSource> sources = [];
|
||||
for(int i=0; i<_queue.length; i++) {
|
||||
sources.add(await _mediaItemToAudioSource(_queue[i]));
|
||||
|
@ -432,9 +500,11 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
//Load in just_audio
|
||||
try {
|
||||
await _player.load(_audioSource);
|
||||
await _player.seek(Duration.zero, index: qi);
|
||||
} catch (e) {
|
||||
//Error loading tracks
|
||||
}
|
||||
_queueIndex = qi;
|
||||
AudioServiceBackground.setMediaItem(mediaItem);
|
||||
}
|
||||
|
||||
|
@ -523,13 +593,14 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
|
||||
//Export queue to JSON
|
||||
Future _saveQueue() async {
|
||||
if (_queueIndex == 0 && _queue.length == 0) return;
|
||||
|
||||
String path = await _getQueuePath();
|
||||
File f = File(path);
|
||||
//Create if doesnt exist
|
||||
//Create if doesn't exist
|
||||
if (! await File(path).exists()) {
|
||||
f = await f.create();
|
||||
}
|
||||
|
||||
Map data = {
|
||||
'index': _queueIndex,
|
||||
'queue': _queue.map<Map<String, dynamic>>((mi) => mi.toJson()).toList(),
|
||||
|
@ -552,7 +623,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
if (_queue != null) {
|
||||
await AudioServiceBackground.setQueue(_queue);
|
||||
await _loadQueue();
|
||||
AudioServiceBackground.setMediaItem(mediaItem);
|
||||
await AudioServiceBackground.setMediaItem(mediaItem);
|
||||
}
|
||||
}
|
||||
//Send restored queue source to ui
|
||||
|
@ -568,7 +639,6 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
|||
//-1 == play next
|
||||
if (index == -1) index = _queueIndex + 1;
|
||||
|
||||
|
||||
_queue.insert(index, mi);
|
||||
await AudioServiceBackground.setQueue(_queue);
|
||||
await _audioSource.insert(index, await _mediaItemToAudioSource(mi));
|
||||
|
|
|
@ -48,6 +48,7 @@ class SpotifyAPI {
|
|||
SpotifyPlaylist playlist = SpotifyPlaylist.fromJson(data);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
|
||||
Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false}) async {
|
||||
doneImporting = false;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue