0.5.0 - Rewritten downloads, many bugfixes
This commit is contained in:
parent
f7cbb09bc1
commit
f2f6b202d1
|
@ -28,6 +28,7 @@ Compile:
|
|||
flutter pub get
|
||||
flutter build apk
|
||||
```
|
||||
NOTE: You have to use own keys, or build debug using `flutter build apk --debug`
|
||||
|
||||
## Telegram group
|
||||
https://t.me/freezerandroid
|
||||
|
@ -46,6 +47,11 @@ Diego Hiro: Portuguese
|
|||
Annexhack: Russian
|
||||
Chino Pacia: Filipino
|
||||
ArcherDelta & PetFix: Spanish
|
||||
Shazzaam: Croatian
|
||||
VIRGIN_KLM: Greek
|
||||
koreezzz: Korean
|
||||
Fwwwwwwwwwweze: French
|
||||
kobyrevah: Hebrew
|
||||
|
||||
### just_audio, audio_service
|
||||
This app depends on modified just_audio and audio_service plugins with Deezer support.
|
||||
|
|
|
@ -1,72 +1,90 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="f.f.freezer">
|
||||
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
||||
|
||||
<!--
|
||||
io.flutter.app.FlutterApplication is an android.app.Application that
|
||||
calls FlutterMain.startInitialization(this); in its onCreate method.
|
||||
In most cases you can leave this as-is, but you if you want to provide
|
||||
additional functionality it is fine to subclass or reimplement
|
||||
FlutterApplication and put your custom class here. -->
|
||||
|
||||
FlutterApplication and put your custom class here.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<application
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Freezer"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
|
||||
<!--
|
||||
Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
to determine the Window background behind the Flutter UI.
|
||||
-->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
android:resource="@style/NormalTheme" />
|
||||
|
||||
<!-- Displays an Android View that continues showing the launch screen
|
||||
<!--
|
||||
Displays an Android View that continues showing the launch screen
|
||||
Drawable until Flutter paints its first frame, then this splash
|
||||
screen fades out. A splash screen is useful to avoid any visual
|
||||
gap between the end of Android's launch screen and the painting of
|
||||
Flutter's first frame. -->
|
||||
Flutter's first frame.
|
||||
-->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.SplashScreenDrawable"
|
||||
android:resource="@drawable/launch_background"
|
||||
/>
|
||||
android:resource="@drawable/launch_background" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<!--
|
||||
Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
|
||||
-->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
|
||||
<service android:name="com.ryanheise.audioservice.AudioService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" >
|
||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,352 @@
|
|||
package f.f.freezer;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.jaudiotagger.tag.FieldKey;
|
||||
import org.jaudiotagger.tag.Tag;
|
||||
import org.jaudiotagger.tag.TagOptionSingleton;
|
||||
import org.jaudiotagger.tag.datatype.Artwork;
|
||||
import org.jaudiotagger.tag.flac.FlacTag;
|
||||
import org.jaudiotagger.tag.id3.ID3v23Tag;
|
||||
import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
|
||||
import org.jaudiotagger.tag.reference.PictureTypes;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Scanner;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
public class Deezer {
|
||||
|
||||
//Get guest SID cookie from deezer.com
|
||||
public static String getSidCookie() throws Exception {
|
||||
URL url = new URL("https://deezer.com/");
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(20000);
|
||||
connection.setRequestMethod("HEAD");
|
||||
String sid = "";
|
||||
for (String cookie : connection.getHeaderFields().get("Set-Cookie")) {
|
||||
if (cookie.startsWith("sid=")) {
|
||||
sid = cookie.split(";")[0].split("=")[1];
|
||||
}
|
||||
}
|
||||
return sid;
|
||||
}
|
||||
|
||||
//Same as gw_light API, but doesn't need authentication
|
||||
public static JSONObject callMobileAPI(String method, String params) throws Exception{
|
||||
String sid = Deezer.getSidCookie();
|
||||
|
||||
URL url = new URL("https://api.deezer.com/1.0/gateway.php?api_key=4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE&sid=" + sid + "&input=3&output=3&method=" + method);
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(20000);
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
|
||||
connection.setRequestProperty("Accept-Language", "*");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("Accept", "*/*");
|
||||
connection.setRequestProperty("Content-Length", Integer.toString(params.getBytes(StandardCharsets.UTF_8).length));
|
||||
|
||||
//Write body
|
||||
DataOutputStream wr = new DataOutputStream(connection.getOutputStream());
|
||||
wr.writeBytes(params);
|
||||
wr.close();
|
||||
//Get response
|
||||
String data = "";
|
||||
Scanner scanner = new Scanner(connection.getInputStream());
|
||||
while (scanner.hasNext()) {
|
||||
data += scanner.nextLine();
|
||||
}
|
||||
|
||||
//Parse JSON
|
||||
JSONObject out = new JSONObject(data);
|
||||
return out;
|
||||
}
|
||||
|
||||
//api.deezer.com/$method/$param
|
||||
public static JSONObject callPublicAPI(String method, String param) throws Exception {
|
||||
URL url = new URL("https://api.deezer.com/" + method + "/" + param);
|
||||
HttpsURLConnection connection = (HttpsURLConnection)url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(20000);
|
||||
connection.connect();
|
||||
|
||||
//Get string data
|
||||
String data = "";
|
||||
Scanner scanner = new Scanner(url.openStream());
|
||||
while (scanner.hasNext()) {
|
||||
data += scanner.nextLine();
|
||||
}
|
||||
|
||||
//Parse JSON
|
||||
JSONObject out = new JSONObject(data);
|
||||
return out;
|
||||
}
|
||||
|
||||
public static int qualityFallback(String trackId, String md5origin, String mediaVersion, int originalQuality) throws Exception {
|
||||
//Create HEAD requests to check if exists
|
||||
URL url = new URL(getTrackUrl(trackId, md5origin, mediaVersion, originalQuality));
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("HEAD");
|
||||
int rc = connection.getResponseCode();
|
||||
//Track not available
|
||||
if (rc > 400) {
|
||||
//Returns -1 if no quality available
|
||||
if (originalQuality == 1) return -1;
|
||||
if (originalQuality == 3) return qualityFallback(trackId, md5origin, mediaVersion, 1);
|
||||
if (originalQuality == 9) return qualityFallback(trackId, md5origin, mediaVersion, 3);
|
||||
}
|
||||
return originalQuality;
|
||||
}
|
||||
|
||||
//Generate track download URL
|
||||
public static String getTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) {
|
||||
try {
|
||||
int magic = 164;
|
||||
|
||||
ByteArrayOutputStream step1 = new ByteArrayOutputStream();
|
||||
step1.write(md5origin.getBytes());
|
||||
step1.write(magic);
|
||||
step1.write(Integer.toString(quality).getBytes());
|
||||
step1.write(magic);
|
||||
step1.write(trackId.getBytes());
|
||||
step1.write(magic);
|
||||
step1.write(mediaVersion.getBytes());
|
||||
//Get MD5
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
md5.update(step1.toByteArray());
|
||||
byte[] digest = md5.digest();
|
||||
String md5hex = bytesToHex(digest).toLowerCase();
|
||||
|
||||
//Step 2
|
||||
ByteArrayOutputStream step2 = new ByteArrayOutputStream();
|
||||
step2.write(md5hex.getBytes());
|
||||
step2.write(magic);
|
||||
step2.write(step1.toByteArray());
|
||||
step2.write(magic);
|
||||
|
||||
//Pad step2 with dots, to get correct length
|
||||
while(step2.size()%16 > 0) step2.write(46);
|
||||
|
||||
//Prepare AES encryption
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
||||
SecretKeySpec key = new SecretKeySpec("jo6aey6haid2Teih".getBytes(), "AES");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||
//Encrypt
|
||||
StringBuilder step3 = new StringBuilder();
|
||||
for (int i=0; i<step2.size()/16; i++) {
|
||||
byte[] b = Arrays.copyOfRange(step2.toByteArray(), i*16, (i+1)*16);
|
||||
step3.append(bytesToHex(cipher.doFinal(b)).toLowerCase());
|
||||
}
|
||||
//Join to URL
|
||||
return "https://e-cdns-proxy-" + md5origin.charAt(0) + ".dzcdn.net/mobile/1/" + step3.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String bytesToHex(byte[] bytes) {
|
||||
final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
|
||||
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
//Calculate decryption key from track id
|
||||
private static byte[] getKey(String id) {
|
||||
String secret = "g4el58wc0zvf9na1";
|
||||
String key = "";
|
||||
try {
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
//md5.update(id.getBytes());
|
||||
byte[] md5id = md5.digest(id.getBytes());
|
||||
String idmd5 = bytesToHex(md5id).toLowerCase();
|
||||
|
||||
for(int i=0; i<16; i++) {
|
||||
int s0 = idmd5.charAt(i);
|
||||
int s1 = idmd5.charAt(i+16);
|
||||
int s2 = secret.charAt(i);
|
||||
key += (char)(s0^s1^s2);
|
||||
}
|
||||
} catch (Exception e) {}
|
||||
return key.getBytes();
|
||||
}
|
||||
|
||||
//Decrypt 2048b chunk
|
||||
private static byte[] decryptChunk(byte[] key, byte[] data) throws Exception{
|
||||
byte[] IV = {00, 01, 02, 03, 04, 05, 06, 07};
|
||||
SecretKeySpec Skey = new SecretKeySpec(key, "Blowfish");
|
||||
Cipher cipher = Cipher.getInstance("Blowfish/CBC/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, Skey, new javax.crypto.spec.IvParameterSpec(IV));
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
|
||||
public static void decryptTrack(String path, String tid) throws Exception {
|
||||
//Load file
|
||||
File inputFile = new File(path);
|
||||
BufferedInputStream buffin = new BufferedInputStream(new FileInputStream(inputFile));
|
||||
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
||||
byte[] key = getKey(tid);
|
||||
for (int i=0; i<(inputFile.length()/2048)+1; i++) {
|
||||
byte[] tmp = new byte[2048];
|
||||
int read = buffin.read(tmp, 0, tmp.length);
|
||||
if ((i%3) == 0 && read == 2048) {
|
||||
tmp = decryptChunk(key, tmp);
|
||||
}
|
||||
buf.write(tmp, 0, read);
|
||||
}
|
||||
//Save
|
||||
FileOutputStream outputStream = new FileOutputStream(new File(path));
|
||||
outputStream.write(buf.toByteArray());
|
||||
outputStream.close();
|
||||
}
|
||||
|
||||
public static String sanitize(String input) {
|
||||
return input.replaceAll("[\\\\/?*:%<>|\"]", "").replace("$", "\\$");
|
||||
}
|
||||
|
||||
public static String generateFilename(String original, JSONObject publicTrack, JSONObject publicAlbum, int newQuality) throws Exception {
|
||||
original = original.replaceAll("%title%", sanitize(publicTrack.getString("title")));
|
||||
original = original.replaceAll("%album%", sanitize(publicTrack.getJSONObject("album").getString("title")));
|
||||
original = original.replaceAll("%artist%", sanitize(publicTrack.getJSONObject("artist").getString("name")));
|
||||
//Artists
|
||||
String artists = "";
|
||||
String feats = "";
|
||||
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
|
||||
artists += ", " + publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
|
||||
if (i > 0)
|
||||
feats += ", " + publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
|
||||
}
|
||||
original = original.replaceAll("%artists%", sanitize(artists).substring(2));
|
||||
if (feats.length() >= 2)
|
||||
original = original.replaceAll("%feats%", sanitize(feats).substring(2));
|
||||
//Track number
|
||||
int trackNumber = publicTrack.getInt("track_position");
|
||||
original = original.replaceAll("%trackNumber%", Integer.toString(trackNumber));
|
||||
original = original.replaceAll("%0trackNumber%", String.format("%02d", trackNumber));
|
||||
//Year
|
||||
original = original.replaceAll("%year%", publicTrack.getString("release_date").substring(0, 4));
|
||||
|
||||
if (newQuality == 9) return original + ".flac";
|
||||
return original + ".mp3";
|
||||
}
|
||||
|
||||
//Tag track with data from API
|
||||
public static void tagTrack(String path, JSONObject publicTrack, JSONObject publicAlbum, String cover) throws Exception {
|
||||
TagOptionSingleton.getInstance().setAndroid(true);
|
||||
//Load file
|
||||
AudioFile f = AudioFileIO.read(new File(path));
|
||||
boolean isFlac = true;
|
||||
if (f.getAudioHeader().getFormat().contains("MPEG")) {
|
||||
f.setTag(new ID3v23Tag());
|
||||
isFlac = false;
|
||||
}
|
||||
Tag tag = f.getTag();
|
||||
|
||||
tag.setField(FieldKey.TITLE, publicTrack.getString("title"));
|
||||
tag.setField(FieldKey.ALBUM, publicTrack.getJSONObject("album").getString("title"));
|
||||
//Artist
|
||||
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
|
||||
tag.addField(FieldKey.ARTIST, publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name"));
|
||||
}
|
||||
tag.setField(FieldKey.TRACK, Integer.toString(publicTrack.getInt("track_position")));
|
||||
tag.setField(FieldKey.DISC_NO, Integer.toString(publicTrack.getInt("disk_number")));
|
||||
tag.setField(FieldKey.ALBUM_ARTIST, publicAlbum.getJSONObject("artist").getString("name"));
|
||||
tag.setField(FieldKey.YEAR, publicTrack.getString("release_date").substring(0, 4));
|
||||
tag.setField(FieldKey.BPM, Integer.toString((int)publicTrack.getDouble("bpm")));
|
||||
tag.setField(FieldKey.RECORD_LABEL, publicAlbum.getString("label"));
|
||||
//Genres
|
||||
for (int i=0; i<publicAlbum.getJSONObject("genres").getJSONArray("data").length(); i++) {
|
||||
String genre = publicAlbum.getJSONObject("genres").getJSONArray("data").getJSONObject(0).getString("name");
|
||||
tag.addField(FieldKey.GENRE, genre);
|
||||
}
|
||||
|
||||
File coverFile = new File(cover);
|
||||
boolean addCover = (coverFile.exists() && coverFile.length() > 0);
|
||||
|
||||
if (isFlac) {
|
||||
//FLAC Specific tags
|
||||
((FlacTag)tag).setField("DATE", publicTrack.getString("release_date"));
|
||||
((FlacTag)tag).setField("TRACKTOTAL", Integer.toString(publicAlbum.getInt("nb_tracks")));
|
||||
//Cover
|
||||
if (addCover) {
|
||||
RandomAccessFile cf = new RandomAccessFile(coverFile, "r");
|
||||
byte[] coverData = new byte[(int) cf.length()];
|
||||
cf.read(coverData);
|
||||
tag.setField(((FlacTag)tag).createArtworkField(
|
||||
coverData,
|
||||
PictureTypes.DEFAULT_ID,
|
||||
ImageFormats.MIME_TYPE_JPEG,
|
||||
"cover",
|
||||
1400,
|
||||
1400,
|
||||
24,
|
||||
0
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if (addCover) {
|
||||
Artwork art = Artwork.createArtworkFromFile(coverFile);
|
||||
tag.addField(art);
|
||||
}
|
||||
}
|
||||
|
||||
//Save
|
||||
AudioFileIO.write(f);
|
||||
}
|
||||
|
||||
//Create JSON file, privateJsonData = `song.getLyrics`
|
||||
public static String generateLRC(JSONObject privateJsonData, JSONObject publicTrack) throws Exception {
|
||||
String output = "";
|
||||
|
||||
//Create metadata
|
||||
String title = publicTrack.getString("title");
|
||||
String album = publicTrack.getJSONObject("album").getString("title");
|
||||
String artists = "";
|
||||
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
|
||||
artists += ", " + publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
|
||||
}
|
||||
//Write metadata
|
||||
output += "[ar:" + artists.substring(2) + "]\r\n[al:" + album + "]\r\n[ti:" + title + "]\r\n";
|
||||
|
||||
//Get lyrics
|
||||
int counter = 0;
|
||||
JSONArray syncLyrics = privateJsonData.getJSONObject("results").getJSONArray("LYRICS_SYNC_JSON");
|
||||
for (int i=0; i<syncLyrics.length(); i++) {
|
||||
JSONObject lyric = syncLyrics.getJSONObject(i);
|
||||
if (lyric.has("lrc_timestamp") && lyric.has("line")) {
|
||||
output += lyric.getString("lrc_timestamp") + lyric.getString("line") + "\r\n";
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (counter == 0) throw new Exception("Empty Lyrics!");
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package f.f.freezer;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import java.util.HashMap;
|
||||
|
||||
|
||||
public class Download {
|
||||
int id;
|
||||
String path;
|
||||
boolean priv;
|
||||
int quality;
|
||||
String trackId;
|
||||
String md5origin;
|
||||
String mediaVersion;
|
||||
DownloadState state;
|
||||
String title;
|
||||
String image;
|
||||
|
||||
//Dynamic
|
||||
long received;
|
||||
long filesize;
|
||||
|
||||
Download(int id, String path, boolean priv, int quality, DownloadState state, String trackId, String md5origin, String mediaVersion, String title, String image) {
|
||||
this.id = id;
|
||||
this.path = path;
|
||||
this.priv = priv;
|
||||
this.trackId = trackId;
|
||||
this.md5origin = md5origin;
|
||||
this.state = state;
|
||||
this.mediaVersion = mediaVersion;
|
||||
this.title = title;
|
||||
this.image = image;
|
||||
this.quality = quality;
|
||||
}
|
||||
|
||||
enum DownloadState {
|
||||
NONE(0),
|
||||
DOWNLOADING (1),
|
||||
POST(2),
|
||||
DONE(3),
|
||||
DEEZER_ERROR(4),
|
||||
ERROR(5);
|
||||
|
||||
private final int value;
|
||||
private DownloadState(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
public int getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
//Get download from SQLite cursor, HAS TO ALIGN
|
||||
static Download fromSQL(Cursor cursor) {
|
||||
return new Download(cursor.getInt(0), cursor.getString(1), cursor.getInt(2) == 1, cursor.getInt(3), DownloadState.values()[cursor.getInt(4)],
|
||||
cursor.getString(5), cursor.getString(6), cursor.getString(7), cursor.getString(8), cursor.getString(9)
|
||||
);
|
||||
}
|
||||
|
||||
//Convert object from method call to SQL ContentValues
|
||||
static ContentValues flutterToSQL(HashMap data) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put("path", (String)data.get("path"));
|
||||
values.put("private", ((boolean)data.get("private")) ? 1 : 0);
|
||||
values.put("state", 0);
|
||||
values.put("trackId", (String)data.get("trackId"));
|
||||
values.put("md5origin", (String)data.get("md5origin"));
|
||||
values.put("mediaVersion", (String)data.get("mediaVersion"));
|
||||
values.put("title", (String)data.get("title"));
|
||||
values.put("image", (String)data.get("image"));
|
||||
values.put("quality", (int)data.get("quality"));
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
//Used to send data to Flutter
|
||||
HashMap toHashMap() {
|
||||
HashMap map = new HashMap();
|
||||
map.put("id", id);
|
||||
map.put("path", path);
|
||||
map.put("private", priv);
|
||||
map.put("quality", quality);
|
||||
map.put("trackId", trackId);
|
||||
map.put("state", state.getValue());
|
||||
map.put("title", title);
|
||||
map.put("image", image);
|
||||
//Only useful data, some are passed in updates
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,709 @@
|
|||
package f.f.freezer;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Message;
|
||||
import android.os.Messenger;
|
||||
import android.os.RemoteException;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
public class DownloadService extends Service {
|
||||
|
||||
//Message commands
|
||||
static final int SERVICE_LOAD_DOWNLOADS = 1;
|
||||
static final int SERVICE_START_DOWNLOAD = 2;
|
||||
static final int SERVICE_ON_PROGRESS = 3;
|
||||
static final int SERVICE_SETTINGS_UPDATE = 4;
|
||||
static final int SERVICE_STOP_DOWNLOADS = 5;
|
||||
static final int SERVICE_ON_STATE_CHANGE = 6;
|
||||
static final int SERVICE_REMOVE_DOWNLOAD = 7;
|
||||
static final int SERVICE_RETRY_DOWNLOADS = 8;
|
||||
static final int SERVICE_REMOVE_DOWNLOADS = 9;
|
||||
|
||||
static final String NOTIFICATION_CHANNEL_ID = "freezerdownloads";
|
||||
static final int NOTIFICATION_ID_START = 6969;
|
||||
|
||||
boolean running = false;
|
||||
DownloadSettings settings;
|
||||
Context context;
|
||||
SQLiteDatabase db;
|
||||
|
||||
Messenger serviceMessenger;
|
||||
Messenger activityMessenger;
|
||||
NotificationManagerCompat notificationManager;
|
||||
|
||||
ArrayList<Download> downloads = new ArrayList<>();
|
||||
ArrayList<DownloadThread> threads = new ArrayList<>();
|
||||
ArrayList<Boolean> updateRequests = new ArrayList<>();
|
||||
ArrayList<String> pendingCovers = new ArrayList<>();
|
||||
boolean updating = false;
|
||||
Handler progressUpdateHandler = new Handler();
|
||||
|
||||
public DownloadService() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
//Setup notifications
|
||||
context = this;
|
||||
notificationManager = NotificationManagerCompat.from(context);
|
||||
createNotificationChannel();
|
||||
createProgressUpdateHandler();
|
||||
|
||||
//Get DB
|
||||
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
|
||||
db = dbHelper.getWritableDatabase();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
//Cancel notifications
|
||||
notificationManager.cancelAll();
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
//Set messengers
|
||||
serviceMessenger = new Messenger(new IncomingHandler(this));
|
||||
if (intent != null)
|
||||
activityMessenger = intent.getParcelableExtra("activityMessenger");
|
||||
|
||||
return serviceMessenger.getBinder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
//Get messenger
|
||||
if (intent != null)
|
||||
activityMessenger = intent.getParcelableExtra("activityMessenger");
|
||||
|
||||
return super.onStartCommand(intent, flags, startId);
|
||||
}
|
||||
|
||||
//Android O+ Notifications
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_MIN);
|
||||
NotificationManager nManager = getSystemService(NotificationManager.class);
|
||||
nManager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
|
||||
//Update download tasks
|
||||
private void updateQueue() {
|
||||
db.beginTransaction();
|
||||
|
||||
//Clear downloaded tracks
|
||||
for (int i=threads.size() - 1; i>=0; i--) {
|
||||
Download.DownloadState state = threads.get(i).download.state;
|
||||
if (state == Download.DownloadState.NONE || state == Download.DownloadState.DONE || state == Download.DownloadState.ERROR || state == Download.DownloadState.DEEZER_ERROR) {
|
||||
Download d = threads.get(i).download;
|
||||
//Update in queue
|
||||
for (int j=0; j<downloads.size(); j++) {
|
||||
if (downloads.get(j).id == d.id) {
|
||||
downloads.set(j, d);
|
||||
}
|
||||
}
|
||||
updateProgress();
|
||||
//Save to DB
|
||||
ContentValues row = new ContentValues();
|
||||
row.put("state", state.getValue());
|
||||
row.put("quality", d.quality);
|
||||
db.update("Downloads", row, "id == ?", new String[]{Integer.toString(d.id)});
|
||||
|
||||
//Update library
|
||||
if (state == Download.DownloadState.DONE && !d.priv) {
|
||||
Uri uri = Uri.fromFile(new File(threads.get(i).outFile.getPath()));
|
||||
sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri));
|
||||
}
|
||||
|
||||
//Remove thread
|
||||
threads.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
db.endTransaction();
|
||||
|
||||
//Create new download tasks
|
||||
if (running) {
|
||||
int nThreads = settings.downloadThreads - threads.size();
|
||||
for (int i = 0; i<nThreads; i++) {
|
||||
for (int j = 0; j < downloads.size(); j++) {
|
||||
if (downloads.get(j).state == Download.DownloadState.NONE) {
|
||||
//Update download
|
||||
Download d = downloads.get(j);
|
||||
d.state = Download.DownloadState.DOWNLOADING;
|
||||
downloads.set(j, d);
|
||||
|
||||
//Create thread
|
||||
DownloadThread thread = new DownloadThread(d);
|
||||
thread.start();
|
||||
threads.add(thread);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
//Check if last download
|
||||
if (threads.size() == 0) {
|
||||
running = false;
|
||||
updateState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Send state change to UI
|
||||
private void updateState() {
|
||||
Bundle b = new Bundle();
|
||||
b.putBoolean("running", running);
|
||||
//Get count of not downloaded tracks
|
||||
int queueSize = 0;
|
||||
for (int i=0; i<downloads.size(); i++) {
|
||||
if (downloads.get(i).state == Download.DownloadState.NONE)
|
||||
queueSize++;
|
||||
}
|
||||
b.putInt("queueSize", queueSize);
|
||||
sendMessage(SERVICE_ON_STATE_CHANGE, b);
|
||||
}
|
||||
|
||||
//Wrapper to prevent threads racing
|
||||
private void updateQueueWrapper() {
|
||||
updateRequests.add(true);
|
||||
if (!updating) {
|
||||
updating = true;
|
||||
while (updateRequests.size() > 0) {
|
||||
updateQueue();
|
||||
updateRequests.remove(0);
|
||||
}
|
||||
}
|
||||
updating = false;
|
||||
}
|
||||
|
||||
//Loads downloads from database
|
||||
private void loadDownloads() {
|
||||
Cursor cursor = db.query("Downloads", null, null, null, null, null, null);
|
||||
|
||||
//Parse downloads
|
||||
while (cursor.moveToNext()) {
|
||||
|
||||
//Duplicate check
|
||||
int downloadId = cursor.getInt(0);
|
||||
Download.DownloadState state = Download.DownloadState.values()[cursor.getInt(1)];
|
||||
boolean skip = false;
|
||||
for (int i=0; i<downloads.size(); i++) {
|
||||
if (downloads.get(i).id == downloadId) {
|
||||
if (downloads.get(i).state != state) {
|
||||
//Different state, update state, only for finished/error
|
||||
if (downloads.get(i).state.getValue() >= 3) {
|
||||
downloads.set(i, Download.fromSQL(cursor));
|
||||
}
|
||||
}
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
//Add to queue
|
||||
if (!skip)
|
||||
downloads.add(Download.fromSQL(cursor));
|
||||
}
|
||||
cursor.close();
|
||||
|
||||
updateState();
|
||||
}
|
||||
|
||||
//Stop downloads
|
||||
private void stop() {
|
||||
running = false;
|
||||
for (int i=0; i<threads.size(); i++) {
|
||||
threads.get(i).stopDownload();
|
||||
}
|
||||
updateState();
|
||||
}
|
||||
|
||||
|
||||
public class DownloadThread extends Thread {
|
||||
|
||||
Download download;
|
||||
File parentDir;
|
||||
File outFile;
|
||||
JSONObject trackJson;
|
||||
JSONObject albumJson;
|
||||
boolean stopDownload = false;
|
||||
DownloadThread(Download download) {
|
||||
this.download = download;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
//Set state
|
||||
download.state = Download.DownloadState.DOWNLOADING;
|
||||
|
||||
//Quality fallback
|
||||
int newQuality;
|
||||
try {
|
||||
newQuality = Deezer.qualityFallback(download.trackId, download.md5origin, download.mediaVersion, download.quality);
|
||||
} catch (Exception e) {
|
||||
Log.e("QF", "Quality fallback failed: " + e.toString());
|
||||
download.state = Download.DownloadState.ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
//No quality available
|
||||
if (newQuality == -1) {
|
||||
download.state = Download.DownloadState.DEEZER_ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
download.quality = newQuality;
|
||||
|
||||
if (!download.priv) {
|
||||
//Fetch metadata
|
||||
try {
|
||||
trackJson = Deezer.callPublicAPI("track", download.trackId);
|
||||
albumJson = Deezer.callPublicAPI("album", Integer.toString(trackJson.getJSONObject("album").getInt("id")));
|
||||
} catch (Exception e) {
|
||||
Log.e("ERR", "Unable to fetch track metadata.");
|
||||
e.printStackTrace();
|
||||
download.state = Download.DownloadState.ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
//Check file
|
||||
try {
|
||||
outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, newQuality));
|
||||
parentDir = new File(outFile.getParent());
|
||||
parentDir.mkdirs();
|
||||
} catch (Exception e) {
|
||||
Log.e("ERR", "Error creating directories! TrackID: " + download.trackId);
|
||||
e.printStackTrace();
|
||||
download.state = Download.DownloadState.ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
//Private track
|
||||
outFile = new File(download.path);
|
||||
parentDir = new File(outFile.getParent());
|
||||
}
|
||||
//File already exists
|
||||
if (outFile.exists()) {
|
||||
//Delete if overwriting enabled
|
||||
if (settings.overwriteDownload) {
|
||||
outFile.delete();
|
||||
} else {
|
||||
download.state = Download.DownloadState.DONE;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//Temporary encrypted file
|
||||
File tmpFile = new File(getCacheDir(), download.id + ".ENC");
|
||||
//Get start bytes offset
|
||||
long start = 0;
|
||||
if (tmpFile.exists()) {
|
||||
start = tmpFile.length();
|
||||
}
|
||||
|
||||
//Download
|
||||
String sURL = Deezer.getTrackUrl(download.trackId, download.md5origin, download.mediaVersion, newQuality);
|
||||
try {
|
||||
URL url = new URL(sURL);
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
//Set headers
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Range", "bytes=" + start + "-");
|
||||
connection.connect();
|
||||
|
||||
//Open streams
|
||||
BufferedInputStream inputStream = new BufferedInputStream(connection.getInputStream());
|
||||
OutputStream outputStream = new FileOutputStream(tmpFile.getPath());
|
||||
//Save total
|
||||
download.filesize = start + connection.getContentLength();
|
||||
//Download
|
||||
byte[] buffer = new byte[4096];
|
||||
long received = 0;
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer, 0, 4096)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
received += read;
|
||||
download.received = start + received;
|
||||
|
||||
//Stop/Cancel download
|
||||
if (stopDownload) {
|
||||
download.state = Download.DownloadState.NONE;
|
||||
try {
|
||||
inputStream.close();
|
||||
outputStream.close();
|
||||
connection.disconnect();
|
||||
} catch (Exception ignored) {}
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
//On done
|
||||
inputStream.close();
|
||||
outputStream.close();
|
||||
connection.disconnect();
|
||||
//Update
|
||||
download.state = Download.DownloadState.POST;
|
||||
updateProgress();
|
||||
} catch (Exception e) {
|
||||
//Download error
|
||||
Log.e("DOWNLOAD", "Download error!");
|
||||
e.printStackTrace();
|
||||
download.state = Download.DownloadState.ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
//Post processing
|
||||
|
||||
//Decrypt
|
||||
try {
|
||||
Deezer.decryptTrack(tmpFile.getPath(), download.trackId);
|
||||
} catch (Exception e) {
|
||||
Log.e("DEC", "Decryption failed!");
|
||||
e.printStackTrace();
|
||||
//Shouldn't ever fail
|
||||
}
|
||||
|
||||
//If exists (duplicate download in DB), don't overwrite.
|
||||
if (outFile.exists()) {
|
||||
download.state = Download.DownloadState.DONE;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
//Copy to destination directory
|
||||
if (!tmpFile.renameTo(outFile)) {
|
||||
download.state = Download.DownloadState.ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!download.priv) {
|
||||
//Download cover
|
||||
File coverFile = new File(parentDir, "cover.jpg");
|
||||
//Wait for another thread to download it
|
||||
while (pendingCovers.contains(coverFile.getPath())) {
|
||||
try { Thread.sleep(100); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
try {
|
||||
//Create fake file so other threads don't start downloading covers
|
||||
coverFile.createNewFile();
|
||||
pendingCovers.add(coverFile.getPath());
|
||||
|
||||
URL url = new URL("http://e-cdn-images.deezer.com/images/cover/" + trackJson.getString("md5_image") + "/1400x1400-000000-80-0-0.jpg");
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
//Set headers
|
||||
connection.setRequestMethod("GET");
|
||||
connection.connect();
|
||||
//Open streams
|
||||
InputStream inputStream = connection.getInputStream();
|
||||
OutputStream outputStream = new FileOutputStream(coverFile.getPath());
|
||||
//Download
|
||||
byte[] buffer = new byte[4096];
|
||||
int read = 0;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
//On done
|
||||
inputStream.close();
|
||||
outputStream.close();
|
||||
connection.disconnect();
|
||||
} catch (Exception e) {
|
||||
Log.e("ERR", "Error downloading cover!");
|
||||
e.printStackTrace();
|
||||
coverFile.delete();
|
||||
}
|
||||
//Remove lock
|
||||
pendingCovers.remove(coverFile.getPath());
|
||||
}
|
||||
|
||||
//Tag
|
||||
try {
|
||||
Deezer.tagTrack(outFile.getPath(), trackJson, albumJson, coverFile.getPath());
|
||||
} catch (Exception e) {
|
||||
Log.e("ERR", "Tagging error!");
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
//Lyrics
|
||||
if (settings.downloadLyrics) {
|
||||
try {
|
||||
JSONObject lyricsData = Deezer.callMobileAPI("song.getLyrics", "{\"sng_id\": " + download.trackId + "}");
|
||||
String lrcData = Deezer.generateLRC(lyricsData, trackJson);
|
||||
//Create file
|
||||
String lrcFilename = outFile.getPath().substring(0, outFile.getPath().lastIndexOf(".")+1) + "lrc";
|
||||
FileOutputStream fileOutputStream = new FileOutputStream(lrcFilename);
|
||||
fileOutputStream.write(lrcData.getBytes());
|
||||
fileOutputStream.close();
|
||||
} catch (Exception e) {
|
||||
Log.w("WAR", "Missing lyrics! " + e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
download.state = Download.DownloadState.DONE;
|
||||
//Queue update
|
||||
updateQueueWrapper();
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
void stopDownload() {
|
||||
stopDownload = true;
|
||||
}
|
||||
|
||||
//Clean stop/exit
|
||||
private void exit() {
|
||||
updateQueueWrapper();
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//500ms loop to update notifications and UI
|
||||
private void createProgressUpdateHandler() {
|
||||
progressUpdateHandler.postDelayed(() -> {
|
||||
updateProgress();
|
||||
createProgressUpdateHandler();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
//Updates notification and UI
|
||||
private void updateProgress() {
|
||||
if (threads.size() > 0) {
|
||||
//Convert threads to bundles, send to activity;
|
||||
Bundle b = new Bundle();
|
||||
ArrayList<Bundle> down = new ArrayList<>();
|
||||
for (int i=0; i<threads.size(); i++) {
|
||||
//Create bundle
|
||||
Download download = threads.get(i).download;
|
||||
down.add(createProgressBundle(download));
|
||||
//Notification
|
||||
updateNotification(download);
|
||||
}
|
||||
b.putParcelableArrayList("downloads", down);
|
||||
sendMessage(SERVICE_ON_PROGRESS, b);
|
||||
}
|
||||
}
|
||||
|
||||
//Create bundle with download progress & state
|
||||
private Bundle createProgressBundle(Download download) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt("id", download.id);
|
||||
bundle.putLong("received", download.received);
|
||||
bundle.putLong("filesize", download.filesize);
|
||||
bundle.putInt("quality", download.quality);
|
||||
bundle.putInt("state", download.state.getValue());
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private void updateNotification(Download download) {
|
||||
//Cancel notification for done/none/error downloads
|
||||
if (download.state == Download.DownloadState.NONE || download.state.getValue() >= 3) {
|
||||
notificationManager.cancel(NOTIFICATION_ID_START + download.id);
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, DownloadService.NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(download.title)
|
||||
.setSmallIcon(R.drawable.ic_logo)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN);
|
||||
|
||||
//Show progress when downloading
|
||||
if (download.state == Download.DownloadState.DOWNLOADING) {
|
||||
if (download.filesize <= 0) download.filesize = 1;
|
||||
notificationBuilder.setContentText(String.format("%s / %s", formatFilesize(download.received), formatFilesize(download.filesize)));
|
||||
notificationBuilder.setProgress(100, (int)((download.received / (float)download.filesize)*100), false);
|
||||
}
|
||||
|
||||
//Indeterminate on PostProcess
|
||||
if (download.state == Download.DownloadState.POST) {
|
||||
//TODO: Use strings
|
||||
notificationBuilder.setContentText("Post processing...");
|
||||
notificationBuilder.setProgress(1, 1, true);
|
||||
}
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID_START + download.id, notificationBuilder.build());
|
||||
}
|
||||
|
||||
//https://stackoverflow.com/questions/3263892/format-file-size-as-mb-gb-etc
|
||||
public static String formatFilesize(long size) {
|
||||
if(size <= 0) return "0B";
|
||||
final String[] units = new String[] { "B", "KB", "MB", "GB", "TB" };
|
||||
int digitGroups = (int) (Math.log10(size)/Math.log10(1024));
|
||||
return new DecimalFormat("#,##0.##").format(size/Math.pow(1024, digitGroups)) + " " + units[digitGroups];
|
||||
}
|
||||
|
||||
//Handler for incoming messages
|
||||
class IncomingHandler extends Handler {
|
||||
IncomingHandler(Context context) {
|
||||
context.getApplicationContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
switch (msg.what) {
|
||||
//Load downloads from DB
|
||||
case SERVICE_LOAD_DOWNLOADS:
|
||||
loadDownloads();
|
||||
break;
|
||||
|
||||
//Start/Resume
|
||||
case SERVICE_START_DOWNLOAD:
|
||||
running = true;
|
||||
updateQueue();
|
||||
updateState();
|
||||
break;
|
||||
|
||||
//Load settings
|
||||
case SERVICE_SETTINGS_UPDATE:
|
||||
settings = DownloadSettings.fromBundle(msg.getData());
|
||||
break;
|
||||
|
||||
//Stop downloads
|
||||
case SERVICE_STOP_DOWNLOADS:
|
||||
stop();
|
||||
break;
|
||||
|
||||
//Remove download
|
||||
case SERVICE_REMOVE_DOWNLOAD:
|
||||
int downloadId = msg.getData().getInt("id");
|
||||
for (int i=0; i<downloads.size(); i++) {
|
||||
Download d = downloads.get(i);
|
||||
//Only remove if not downloading
|
||||
if (d.id == downloadId) {
|
||||
if (d.state == Download.DownloadState.DOWNLOADING || d.state == Download.DownloadState.POST) {
|
||||
return;
|
||||
}
|
||||
downloads.remove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
//Remove from DB
|
||||
db.delete("Downloads", "id == ?", new String[]{Integer.toString(downloadId)});
|
||||
updateState();
|
||||
break;
|
||||
|
||||
//Retry failed downloads
|
||||
case SERVICE_RETRY_DOWNLOADS:
|
||||
db.beginTransaction();
|
||||
for (int i=0; i<downloads.size(); i++) {
|
||||
Download d = downloads.get(i);
|
||||
if (d.state == Download.DownloadState.DEEZER_ERROR || d.state == Download.DownloadState.ERROR) {
|
||||
//Retry only failed
|
||||
d.state = Download.DownloadState.NONE;
|
||||
downloads.set(i, d);
|
||||
//Update DB
|
||||
ContentValues values = new ContentValues();
|
||||
values.put("state", 0);
|
||||
db.update("Downloads", values, "id == ?", new String[]{Integer.toString(d.id)});
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful();
|
||||
db.endTransaction();
|
||||
updateState();
|
||||
break;
|
||||
|
||||
//Remove downloads by state
|
||||
case SERVICE_REMOVE_DOWNLOADS:
|
||||
//Don't remove currently downloading, user has to stop first
|
||||
Download.DownloadState state = Download.DownloadState.values()[msg.getData().getInt("state")];
|
||||
if (state == Download.DownloadState.DOWNLOADING || state == Download.DownloadState.POST) return;
|
||||
|
||||
db.beginTransaction();
|
||||
int i = (downloads.size() - 1);
|
||||
while (i >= 0) {
|
||||
Download d = downloads.get(i);
|
||||
if (d.state == state) {
|
||||
//Remove
|
||||
db.delete("Downloads", "id == ?", new String[]{Integer.toString(d.id)});
|
||||
downloads.remove(i);
|
||||
}
|
||||
i--;
|
||||
}
|
||||
//Delete from DB, done downloads after app restart aren't in downloads array
|
||||
db.delete("Downloads", "state == ?", new String[]{Integer.toString(msg.getData().getInt("state"))});
|
||||
//Save
|
||||
db.setTransactionSuccessful();
|
||||
db.endTransaction();
|
||||
updateState();
|
||||
break;
|
||||
|
||||
default:
|
||||
super.handleMessage(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Send message to MainActivity
|
||||
void sendMessage(int type, Bundle data) {
|
||||
if (serviceMessenger != null) {
|
||||
Message msg = Message.obtain(null, type);
|
||||
msg.setData(data);
|
||||
try {
|
||||
activityMessenger.send(msg);
|
||||
} catch (RemoteException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class DownloadSettings {
|
||||
|
||||
int downloadThreads;
|
||||
boolean overwriteDownload;
|
||||
boolean downloadLyrics;
|
||||
|
||||
private DownloadSettings(int downloadThreads, boolean overwriteDownload, boolean downloadLyrics) {
|
||||
this.downloadThreads = downloadThreads;
|
||||
this.overwriteDownload = overwriteDownload;
|
||||
this.downloadLyrics = downloadLyrics;
|
||||
}
|
||||
|
||||
//Parse settings from bundle sent from UI
|
||||
static DownloadSettings fromBundle(Bundle b) {
|
||||
return new DownloadSettings(b.getInt("downloadThreads"), b.getBoolean("overwriteDownload"), b.getBoolean("downloadLyrics"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package f.f.freezer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteOpenHelper;
|
||||
|
||||
public class DownloadsDatabase extends SQLiteOpenHelper {
|
||||
|
||||
public static final int DATABASE_VERSION = 1;
|
||||
|
||||
public DownloadsDatabase(Context context) {
|
||||
super(context, context.getDatabasePath("downloads").toString(), null, DATABASE_VERSION);
|
||||
}
|
||||
|
||||
public void onCreate(SQLiteDatabase db) {
|
||||
/*
|
||||
Downloads:
|
||||
id - Download ID (to prevent private/public duplicates)
|
||||
path - Folder name, actual path calculated later,
|
||||
private - 1 = Offline, 0 = Download,
|
||||
quality = Deezer quality int,
|
||||
state = DownloadState value
|
||||
trackId - Track ID,
|
||||
md5origin - MD5Origin,
|
||||
mediaVersion - MediaVersion
|
||||
title - Download/Track name, for display,
|
||||
image - URL to art (for display)
|
||||
*/
|
||||
|
||||
db.execSQL("CREATE TABLE Downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
"path TEXT, private INTEGER, quality INTEGER, state INTEGER, trackId TEXT, md5origin TEXT, mediaVersion TEXT, title TEXT, image TEXT);");
|
||||
}
|
||||
|
||||
|
||||
|
||||
//TODO: Currently does nothing
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
onCreate(db);
|
||||
}
|
||||
public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
onCreate(db);
|
||||
}
|
||||
}
|
|
@ -1,43 +1,295 @@
|
|||
package f.f.freezer;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Message;
|
||||
import android.os.Messenger;
|
||||
import android.os.RemoteException;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.jaudiotagger.tag.FieldKey;
|
||||
import org.jaudiotagger.tag.Tag;
|
||||
import org.jaudiotagger.tag.TagOptionSingleton;
|
||||
import org.jaudiotagger.tag.datatype.Artwork;
|
||||
import org.jaudiotagger.tag.flac.FlacTag;
|
||||
import org.jaudiotagger.tag.id3.ID3v23Tag;
|
||||
import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
|
||||
import org.jaudiotagger.tag.reference.PictureTypes;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.function.Function;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity;
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugins.GeneratedPluginRegistrant;
|
||||
|
||||
import static f.f.freezer.Deezer.bytesToHex;
|
||||
|
||||
public class MainActivity extends FlutterActivity {
|
||||
private static final String CHANNEL = "f.f.freezer/native";
|
||||
private static final String EVENT_CHANNEL = "f.f.freezer/downloads";
|
||||
EventChannel.EventSink eventSink;
|
||||
|
||||
boolean serviceBound = false;
|
||||
Messenger serviceMessenger;
|
||||
Messenger activityMessenger;
|
||||
SQLiteDatabase db;
|
||||
|
||||
private static final int SD_PERMISSION_REQUEST_CODE = 42;
|
||||
|
||||
@Override
|
||||
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
|
||||
GeneratedPluginRegistrant.registerWith(flutterEngine);
|
||||
|
||||
//Flutter method channel
|
||||
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL).setMethodCallHandler((((call, result) -> {
|
||||
|
||||
//Add downloads to DB, then refresh service
|
||||
if (call.method.equals("addDownloads")) {
|
||||
//TX
|
||||
db.beginTransaction();
|
||||
|
||||
ArrayList<HashMap> downloads = call.arguments();
|
||||
for (int i=0; i<downloads.size(); i++) {
|
||||
//Check if exists
|
||||
Cursor cursor = db.rawQuery("SELECT id, state FROM Downloads WHERE trackId == ? AND path == ?",
|
||||
new String[]{(String)downloads.get(i).get("trackId"), (String)downloads.get(i).get("path")});
|
||||
if (cursor.getCount() > 0) {
|
||||
//If done or error, set state to NONE - they should be skipped because file exists
|
||||
cursor.moveToNext();
|
||||
if (cursor.getInt(1) >= 3) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put("state", 0);
|
||||
db.update("Downloads", values, "id == ?", new String[]{Integer.toString(cursor.getInt(0))});
|
||||
Log.d("INFO", "Already exists in DB, updating to none state!");
|
||||
} else {
|
||||
Log.d("INFO", "Already exits in DB!");
|
||||
}
|
||||
cursor.close();
|
||||
continue;
|
||||
}
|
||||
cursor.close();
|
||||
|
||||
//Insert
|
||||
ContentValues row = Download.flutterToSQL(downloads.get(i));
|
||||
db.insert("Downloads", null, row);
|
||||
}
|
||||
db.setTransactionSuccessful();
|
||||
db.endTransaction();
|
||||
//Update service
|
||||
sendMessage(DownloadService.SERVICE_LOAD_DOWNLOADS, null);
|
||||
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
|
||||
//Get all downloads from DB
|
||||
if (call.method.equals("getDownloads")) {
|
||||
Cursor cursor = db.query("Downloads", null, null, null, null, null, null);
|
||||
ArrayList downloads = new ArrayList();
|
||||
//Parse downloads
|
||||
while (cursor.moveToNext()) {
|
||||
Download download = Download.fromSQL(cursor);
|
||||
downloads.add(download.toHashMap());
|
||||
}
|
||||
cursor.close();
|
||||
result.success(downloads);
|
||||
return;
|
||||
}
|
||||
//Update settings from UI
|
||||
if (call.method.equals("updateSettings")) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt("downloadThreads", (int)call.argument("downloadThreads"));
|
||||
bundle.putBoolean("overwriteDownload", (boolean)call.argument("overwriteDownload"));
|
||||
bundle.putBoolean("downloadLyrics", (boolean)call.argument("downloadLyrics"));
|
||||
sendMessage(DownloadService.SERVICE_SETTINGS_UPDATE, bundle);
|
||||
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
//Load downloads from DB in service
|
||||
if (call.method.equals("loadDownloads")) {
|
||||
sendMessage(DownloadService.SERVICE_LOAD_DOWNLOADS, null);
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
//Start/Resume downloading
|
||||
if (call.method.equals("start")) {
|
||||
sendMessage(DownloadService.SERVICE_START_DOWNLOAD, null);
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
//Stop downloading
|
||||
if (call.method.equals("stop")) {
|
||||
sendMessage(DownloadService.SERVICE_STOP_DOWNLOADS, null);
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
//Remove download
|
||||
if (call.method.equals("removeDownload")) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt("id", (int)call.argument("id"));
|
||||
sendMessage(DownloadService.SERVICE_REMOVE_DOWNLOAD, bundle);
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
//Retry download
|
||||
if (call.method.equals("retryDownloads")) {
|
||||
sendMessage(DownloadService.SERVICE_RETRY_DOWNLOADS, null);
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
//Remove downloads by state
|
||||
if (call.method.equals("removeDownloads")) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt("state", (int)call.argument("state"));
|
||||
sendMessage(DownloadService.SERVICE_REMOVE_DOWNLOADS, bundle);
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
result.error("0", "Not implemented!", "Not implemented!");
|
||||
})));
|
||||
|
||||
|
||||
//Event channel (for download updates)
|
||||
EventChannel eventChannel = new EventChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), EVENT_CHANNEL);
|
||||
eventChannel.setStreamHandler((new EventChannel.StreamHandler() {
|
||||
@Override
|
||||
public void onListen(Object arguments, EventChannel.EventSink events) {
|
||||
eventSink = events;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(Object arguments) {
|
||||
eventSink = null;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
//Bind downloader service
|
||||
activityMessenger = new Messenger(new IncomingHandler(this));
|
||||
Intent intent = new Intent(this, DownloadService.class);
|
||||
intent.putExtra("activityMessenger", activityMessenger);
|
||||
bindService(intent, connection, Context.BIND_AUTO_CREATE);
|
||||
//Get DB
|
||||
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
|
||||
db = dbHelper.getWritableDatabase();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
//Unbind service on exit
|
||||
if (serviceBound) {
|
||||
unbindService(connection);
|
||||
serviceBound = false;
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
//Connection to download service
|
||||
private ServiceConnection connection = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
|
||||
serviceMessenger = new Messenger(iBinder);
|
||||
serviceBound = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName componentName) {
|
||||
serviceMessenger = null;
|
||||
serviceBound = false;
|
||||
}
|
||||
};
|
||||
|
||||
//Handler for incoming messages from service
|
||||
class IncomingHandler extends Handler {
|
||||
IncomingHandler(Context context) {
|
||||
Context applicationContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
switch (msg.what) {
|
||||
|
||||
//Forward to flutter.
|
||||
case DownloadService.SERVICE_ON_PROGRESS:
|
||||
if (eventSink == null) break;
|
||||
if (msg.getData().getParcelableArrayList("downloads").size() > 0) {
|
||||
//Generate HashMap ArrayList for sending to flutter
|
||||
ArrayList<HashMap> data = new ArrayList<>();
|
||||
for (int i=0; i<msg.getData().getParcelableArrayList("downloads").size(); i++) {
|
||||
Bundle bundle = (Bundle) msg.getData().getParcelableArrayList("downloads").get(i);
|
||||
HashMap out = new HashMap();
|
||||
out.put("id", bundle.getInt("id"));
|
||||
out.put("state", bundle.getInt("state"));
|
||||
out.put("received", bundle.getLong("received"));
|
||||
out.put("filesize", bundle.getLong("filesize"));
|
||||
out.put("quality", bundle.getInt("quality"));
|
||||
data.add(out);
|
||||
}
|
||||
//Wrapper
|
||||
HashMap out = new HashMap();
|
||||
out.put("action", "onProgress");
|
||||
out.put("data", data);
|
||||
eventSink.success(out);
|
||||
}
|
||||
|
||||
break;
|
||||
//State change, forward to flutter
|
||||
case DownloadService.SERVICE_ON_STATE_CHANGE:
|
||||
if (eventSink == null) break;
|
||||
Bundle b = msg.getData();
|
||||
HashMap out = new HashMap();
|
||||
out.put("running", b.getBoolean("running"));
|
||||
out.put("queueSize", b.getInt("queueSize"));
|
||||
|
||||
//Wrapper info
|
||||
out.put("action", "onStateChange");
|
||||
|
||||
eventSink.success(out);
|
||||
break;
|
||||
|
||||
default:
|
||||
super.handleMessage(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Send message to service
|
||||
void sendMessage(int type, Bundle data) {
|
||||
if (serviceBound && serviceMessenger != null) {
|
||||
Message msg = Message.obtain(null, type);
|
||||
msg.setData(data);
|
||||
try {
|
||||
serviceMessenger.send(msg);
|
||||
} catch (RemoteException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@Override
|
||||
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine);
|
||||
|
@ -132,69 +384,5 @@ public class MainActivity extends FlutterActivity {
|
|||
}));
|
||||
}
|
||||
|
||||
public static void decryptTrack(String path, String tid) {
|
||||
try {
|
||||
//Load file
|
||||
File inputFile = new File(path + ".ENC");
|
||||
BufferedInputStream buffin = new BufferedInputStream(new FileInputStream(inputFile));
|
||||
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
||||
byte[] key = getKey(tid);
|
||||
for (int i=0; i<inputFile.length()/2048; i++) {
|
||||
byte[] tmp = new byte[2048];
|
||||
buffin.read(tmp, 0, tmp.length);
|
||||
if ((i%3) == 0) {
|
||||
tmp = decryptChunk(key, tmp);
|
||||
}
|
||||
buf.write(tmp);
|
||||
}
|
||||
//Save
|
||||
FileOutputStream outputStream = new FileOutputStream(new File(path));
|
||||
outputStream.write(buf.toByteArray());
|
||||
outputStream.close();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static String bytesToHex(byte[] bytes) {
|
||||
final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
|
||||
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
//Calculate decryption key from track id
|
||||
public static byte[] getKey(String id) {
|
||||
String secret = "g4el58wc0zvf9na1";
|
||||
String key = "";
|
||||
try {
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
//md5.update(id.getBytes());
|
||||
byte[] md5id = md5.digest(id.getBytes());
|
||||
String idmd5 = bytesToHex(md5id).toLowerCase();
|
||||
|
||||
for(int i=0; i<16; i++) {
|
||||
int s0 = idmd5.charAt(i);
|
||||
int s1 = idmd5.charAt(i+16);
|
||||
int s2 = secret.charAt(i);
|
||||
key += (char)(s0^s1^s2);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
}
|
||||
return key.getBytes();
|
||||
}
|
||||
|
||||
//Decrypt 2048b chunk
|
||||
public static byte[] decryptChunk(byte[] key, byte[] data) throws Exception{
|
||||
byte[] IV = {00, 01, 02, 03, 04, 05, 06, 07};
|
||||
SecretKeySpec Skey = new SecretKeySpec(key, "Blowfish");
|
||||
Cipher cipher = Cipher.getInstance("Blowfish/CBC/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, Skey, new javax.crypto.spec.IvParameterSpec(IV));
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 73fce9905f9ffeec0270f7c89b70cd0eaa762fb6
|
||||
Subproject commit b268066d26c1cc28183bfc3f0f4ab60d31ebf1f7
|
|
@ -1 +1 @@
|
|||
Subproject commit 884bc7a26960973f8690aacb14a45f2d303fc676
|
||||
Subproject commit ae319b96899fe42a69c57c2f4a17691d90596f98
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
|
@ -724,3 +733,27 @@ enum RepeatType {
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -49,6 +49,7 @@ class SpotifyAPI {
|
|||
return playlist;
|
||||
}
|
||||
|
||||
|
||||
Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false}) async {
|
||||
doneImporting = false;
|
||||
importingSpotifyPlaylist = playlist;
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
const language_gr_el = {
|
||||
"gr_el": {
|
||||
/*
|
||||
|
||||
Translated by: VIRGIN_KLM
|
||||
|
||||
*/
|
||||
|
||||
const language_el_gr = {
|
||||
"el_gr": {
|
||||
"Home": "Αρχική",
|
||||
"Search": "Αναζήτηση",
|
||||
"Library": "Βιβλιοθήκη",
|
||||
|
|
|
@ -160,7 +160,7 @@ const language_en_us = {
|
|||
"Clear": "Clear",
|
||||
"Create folders for artist": "Create folders for artist",
|
||||
"Create folders for albums": "Create folders for albums",
|
||||
"Separate albums by discs": "Separate albums by discs",
|
||||
"Separate albums by discs": "Separate albums by disks",
|
||||
"Overwrite already downloaded files": "Overwrite already downloaded files",
|
||||
"Copy ARL": "Copy ARL",
|
||||
"Copy userToken/ARL Cookie for use in other apps.":
|
||||
|
@ -182,6 +182,33 @@ const language_en_us = {
|
|||
"Radio": "Radio",
|
||||
"Flow": "Flow",
|
||||
"Track is not available on Deezer!": "Track is not available on Deezer!",
|
||||
"Failed to download track! Please restart.": "Failed to download track! Please restart."
|
||||
"Failed to download track! Please restart.": "Failed to download track! Please restart.",
|
||||
|
||||
//0.5.0 Strings:
|
||||
"Storage permission denied!": "Storage permission denied!",
|
||||
"Failed": "Failed",
|
||||
"Queued": "Queued",
|
||||
"External": "External",
|
||||
"Restart failed downloads": "Restart failed downloads",
|
||||
"Clear failed": "Clear failed",
|
||||
"Download Settings": "Download Settings",
|
||||
"Create folder for playlist": "Create folder for playlist",
|
||||
"Download .LRC lyrics": "Download .LRC lyrics",
|
||||
"Proxy": "Proxy",
|
||||
"Not set": "Not set",
|
||||
"Search or paste URL": "Search or paste URL",
|
||||
"History": "History",
|
||||
"Download threads": "Download threads",
|
||||
"Lyrics unavailable, empty or failed to load!": "Lyrics unavailable, empty or failed to load!",
|
||||
"About": "About",
|
||||
"Telegram Channel": "Telegram Channel",
|
||||
"To get latest releases": "To get latest releases",
|
||||
"Official chat": "Official chat",
|
||||
"Telegram Group": "Telegram Group",
|
||||
"Huge thanks to all the contributors! <3": "Huge thanks to all the contributors! <3",
|
||||
"Edit playlist": "Edit playlist",
|
||||
"Update": "Update",
|
||||
"Playlist updated!": "Playlist updated!",
|
||||
"Downloads added!": "Downloads added!"
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
|
||||
Translated by: Fwwwwwwwwwweze
|
||||
|
||||
*/
|
||||
|
||||
const language_fr_fr = {
|
||||
"fr_fr": {
|
||||
"Home": "Acceuil",
|
||||
"Search": "Recherche",
|
||||
"Library": "Bibliothèque",
|
||||
"Offline mode, can't play flow or smart track lists.":
|
||||
"Le mode hors connexion ne permet pas d'accéder à votre Flow.",
|
||||
"Added to library": "Ajouté à la bibliothèque",
|
||||
"Download": "Télécharger",
|
||||
"Disk": "Disque",
|
||||
"Offline": "Hors connnexion",
|
||||
"Top Tracks": "Top Tracks",
|
||||
"Show more tracks": "Afficher plus de pistes",
|
||||
"Top": "Top",
|
||||
"Top Albums": "Top Albums",
|
||||
"Show all albums": "Afficher tous les albums",
|
||||
"Discography": "Discographie",
|
||||
"Default": "Par défaut",
|
||||
"Reverse": "Inverse",
|
||||
"Alphabetic": "Alphabétique",
|
||||
"Artist": "Artiste",
|
||||
"Post processing...": "Post-traitement...",
|
||||
"Done": "Effectué",
|
||||
"Delete": "Supprimer",
|
||||
"Are you sure you want to delete this download?":
|
||||
"Êtes-vous certain de vouloir supprimer ce téléchargement ?",
|
||||
"Cancel": "Annuler",
|
||||
"Downloads": "Téléchargements",
|
||||
"Clear queue": "Effacer file d'attente",
|
||||
"This won't delete currently downloading item":
|
||||
"Ceci ne supprimera pas l'élément en cours de téléchargement",
|
||||
"Are you sure you want to delete all queued downloads?":
|
||||
"Êtes-vous sûr de vouloir supprimer tous les téléchargements en file d'attente ?",
|
||||
"Clear downloads history": "Effacer l'historique des téléchargements",
|
||||
"WARNING: This will only clear non-offline (external downloads)":
|
||||
"AVERTISSEMENT: Ceci n'effacera que les téléchargements non hors connexion (téléchargements externes)",
|
||||
"Please check your connection and try again later...":
|
||||
"Veuillez vérifier votre connexion et réessayer plus tard...",
|
||||
"Show more": "Plus d'informations",
|
||||
"Importer": "Importer",
|
||||
"Currently supporting only Spotify, with 100 tracks limit":
|
||||
"Ne fonctionne qu'avec Spotify pour le moment, avec une limite de 100 pistes",
|
||||
"Due to API limitations": "En raison des limitations de l'API",
|
||||
"Enter your playlist link below":
|
||||
"Coller le lien de votre playlist ci-dessous",
|
||||
"Error loading URL!": "Erreur de chargement de l'URL!",
|
||||
"Convert": "Convertir",
|
||||
"Download only": "Téléchargement uniquement",
|
||||
"Downloading is currently stopped, click here to resume.":
|
||||
"Le téléchargement est actuellement arrêté, cliquez ici pour le reprendre.",
|
||||
"Tracks": "Pistes",
|
||||
"Albums": "Albums",
|
||||
"Artists": "Artistes",
|
||||
"Playlists": "Playlists",
|
||||
"Import": "Importer",
|
||||
"Import playlists from Spotify": "Importer des playlists depuis Spotify",
|
||||
"Statistics": "Statistiques",
|
||||
"Offline tracks": "Pistes hors connexion",
|
||||
"Offline albums": "Albums hors connexion",
|
||||
"Offline playlists": "Playlists hors connexion",
|
||||
"Offline size": "Taille des fichiers hors connexion",
|
||||
"Free space": "Espace libre",
|
||||
"Loved tracks": "Coups de cœur",
|
||||
"Favorites": "Favoris",
|
||||
"All offline tracks": "Toutes les pistes hors connexion",
|
||||
"Create new playlist": "Créer une nouvelle playlist",
|
||||
"Cannot create playlists in offline mode":
|
||||
"Création de playlists impossible en mode hors connexion",
|
||||
"Error": "Erreur",
|
||||
"Error logging in! Please check your token and internet connection and try again.":
|
||||
"Erreur de connexion ! Veuillez vérifier votre token et votre connexion internet et réessayer.",
|
||||
"Dismiss": "Abandonner",
|
||||
"Welcome to": "Bienvenue sur",
|
||||
"Please login using your Deezer account.":
|
||||
"Veuillez vous connecter en utilisant votre compte Deezer.",
|
||||
"Login using browser": "Connexion via navigateur",
|
||||
"Login using token": "Connexion via token",
|
||||
"Enter ARL": "Saisir ARL",
|
||||
"Token (ARL)": "Token (ARL)",
|
||||
"Save": "Sauvegarder",
|
||||
"If you don't have account, you can register on deezer.com for free.":
|
||||
"Si vous n'avez pas de compte, vous pouvez vous inscrire gratuitement sur deezer.com.",
|
||||
"Open in browser": "Ouvrir dans le navigateur",
|
||||
"By using this app, you don't agree with the Deezer ToS":
|
||||
"En utilisant cette application, vous ne respectez pas les CGU de Deezer",
|
||||
"Play next": "Écouter juste après",
|
||||
"Add to queue": "Ajouter à la file d'attente",
|
||||
"Add track to favorites": "Ajouter aux Coups de cœur",
|
||||
"Add to playlist": "Ajouter à une playlist",
|
||||
"Select playlist": "Choisir une playlist",
|
||||
"Track added to": "Piste ajoutée à",
|
||||
"Remove from playlist": "Retirer de la playlist",
|
||||
"Track removed from": "Piste retirée de",
|
||||
"Remove favorite": "Supprimer Coup de cœur ",
|
||||
"Track removed from library": "Piste supprimée de la bibliothèque",
|
||||
"Go to": "Aller à",
|
||||
"Make offline": "Rendre hors connexion",
|
||||
"Add to library": "Ajouter à la bibliothèque",
|
||||
"Remove album": "Supprimer l'album",
|
||||
"Album removed": "Album supprimé",
|
||||
"Remove from favorites": "Retirer des Coups de cœur",
|
||||
"Artist removed from library": "Artiste supprimé de la bibliothèque",
|
||||
"Add to favorites": "Ajouter aux Coups de cœur",
|
||||
"Remove from library": "Retirer de la bibliothèque",
|
||||
"Add playlist to library": "Ajouter la playlist à la bibliothèque",
|
||||
"Added playlist to library": "Playlist ajoutée à la bibliothèque",
|
||||
"Make playlist offline": "Rendre la playlist hors connexion",
|
||||
"Download playlist": "Télécharger la playlist",
|
||||
"Create playlist": "Créer une playlist",
|
||||
"Title": "Titre",
|
||||
"Description": "Description",
|
||||
"Private": "Privée",
|
||||
"Collaborative": "Collaborative",
|
||||
"Create": "Créer",
|
||||
"Playlist created!": "Playlist créée !",
|
||||
"Playing from:": "Lecture à partir de :",
|
||||
"Queue": "File d'attente",
|
||||
"Offline search": "Recherche hors connexion",
|
||||
"Search Results": "Résultats de la recherche",
|
||||
"No results!": "Aucun résultat !",
|
||||
"Show all tracks": "Afficher toutes les pistes",
|
||||
"Show all playlists": "Afficher toutes les playlists",
|
||||
"Settings": "Paramètres",
|
||||
"General": "Général",
|
||||
"Appearance": "Apparence",
|
||||
"Quality": "Qualité",
|
||||
"Deezer": "Deezer",
|
||||
"Theme": "Thème",
|
||||
"Currently": "Actuellement",
|
||||
"Select theme": "Selectionner un thème",
|
||||
"Light (default)": "Clair (Par défaut)",
|
||||
"Dark": "Sombre",
|
||||
"Black (AMOLED)": "Noir (AMOLED)",
|
||||
"Deezer (Dark)": "Deezer (Sombre)",
|
||||
"Primary color": "Couleur principale",
|
||||
"Selected color": "Couleur sélectionnée",
|
||||
"Use album art primary color":
|
||||
"Utiliser la couleur dominante de la pochette en tant que couleur principale",
|
||||
"Warning: might be buggy": "Attention : peut être buggé",
|
||||
"Mobile streaming": "Streaming via réseau mobile",
|
||||
"Wifi streaming": "Streaming via Wifi",
|
||||
"External downloads": "Téléchargements externes",
|
||||
"Content language": "Langue du contenu",
|
||||
"Not app language, used in headers. Now":
|
||||
"Pas la langue de l'appli, utilisée dans les en-têtes de catégories. Actuellement",
|
||||
"Select language": "Selectionner la langue",
|
||||
"Content country": "Pays contenu",
|
||||
"Country used in headers. Now":
|
||||
"Pays utilisé pour les bannières. Actuellement",
|
||||
"Log tracks": "Journal d'écoute",
|
||||
"Send track listen logs to Deezer, enable it for features like Flow to work properly":
|
||||
"Envoie les journaux d'écoute à Deezer, activez-le pour que les fonctionnalités comme Flow fonctionnent correctement",
|
||||
"Offline mode": "Mode hors connexion",
|
||||
"Will be overwritten on start.": "Sera écrasé au démarrage.",
|
||||
"Error logging in, check your internet connections.":
|
||||
"Erreur de connexion, vérifiez votre connexion internet",
|
||||
"Logging in...": "Connexion...",
|
||||
"Download path": "Emplacement des téléchargements",
|
||||
"Downloads naming": "Désignation des téléchargement",
|
||||
"Downloaded tracks filename": "nom de fichier des pistes téléchargées",
|
||||
"Valid variables are": "Les variables valides sont",
|
||||
"Reset": "Réinitialiser",
|
||||
"Clear": "Effacer",
|
||||
"Create folders for artist": "Créer des dossiers par artiste",
|
||||
"Create folders for albums": "Créer des dossiers par album",
|
||||
"Separate albums by discs": "Séparer les albums par disques",
|
||||
"Overwrite already downloaded files":
|
||||
"Écraser les fichiers déjà téléchargés",
|
||||
"Copy ARL": "Copier ARL",
|
||||
"Copy userToken/ARL Cookie for use in other apps.":
|
||||
"Copier le Cookie userToken/ARL pour l'utiliser dans d'autres applications.",
|
||||
"Copied": "Copié",
|
||||
"Log out": "Déconnexion",
|
||||
"Due to plugin incompatibility, login using browser is unavailable without restart.":
|
||||
"En raison d'une incompatibilité de plugin, la connexion à l'aide du navigateur est impossible sans redémarrage.",
|
||||
"(ARL ONLY) Continue": "(ARL SEULEMENT) Continuer",
|
||||
"Log out & Exit": "Se déconnecter et quitter",
|
||||
"Pick-a-Path": "Choissez un emplacement",
|
||||
"Select storage": "Selectionner le stockage",
|
||||
"Go up": "Remonter",
|
||||
"Permission denied": "Autorisation refusée",
|
||||
"Language": "Langue",
|
||||
"Language changed, please restart Freezer to apply!":
|
||||
"Langue modifiée, veuillez redémarrer Freezer pour que les changements prennent effet!",
|
||||
"Importing...": "Importation...",
|
||||
"Radio": "Radio",
|
||||
"Flow": "Flow",
|
||||
"Track is not available on Deezer!":
|
||||
"La piste n'est pas disponible sur Deezer!",
|
||||
"Failed to download track! Please restart.":
|
||||
"Echec du téléchargement de la piste ! Veuillez réessayer."
|
||||
}
|
||||
};
|
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
|
||||
Translated by: kobyrevah
|
||||
|
||||
*/
|
||||
|
||||
const language_he_il = {
|
||||
"he_il": {
|
||||
"Home": "בית",
|
||||
"Search": "חיפוש",
|
||||
"Library": "ספריה",
|
||||
"Offline mode, can't play flow or smart track lists.":
|
||||
"מצב לא מקוון, לא יכול לנגן flow או רשימות שירים חכמות.",
|
||||
"Added to library": "הוסף לסיפרייה",
|
||||
"Download": "הורד",
|
||||
"Disk": "דיסק",
|
||||
"Offline": "לא מקוון",
|
||||
"Top Tracks": "השירים שבטופ",
|
||||
"Show more tracks": "הראה עוד שירים",
|
||||
"Top": "טופ",
|
||||
"Top Albums": "האלבומים המובילים",
|
||||
"Show all albums": "הראה את כל האלבומים",
|
||||
"Discography": "דיסקוגרפיה",
|
||||
"Default": "ברירת מחדל",
|
||||
"Reverse": "הפוך",
|
||||
"Alphabetic": "אלפבתי",
|
||||
"Artist": "אמן",
|
||||
"Post processing...": "לאחר עיבוד...",
|
||||
"Done": "בוצע",
|
||||
"Delete": "מחק",
|
||||
"Are you sure you want to delete this download?":
|
||||
"האם אתה בטוח שאתה רוצה למחוק את ההורדה הזאת?",
|
||||
"Cancel": "בטל",
|
||||
"Downloads": "הורדות",
|
||||
"Clear queue": "נקה תור ",
|
||||
"This won't delete currently downloading item":
|
||||
"פעולה זו לא תמחק את הפריט שמורד עכשיו",
|
||||
"Are you sure you want to delete all queued downloads?":
|
||||
"האם אתה בטוח שאתה רוצה למחוק את כל ההורדות שבתור?",
|
||||
"Clear downloads history": "נקה היסטורית הורדות",
|
||||
"WARNING: This will only clear non-offline (external downloads)":
|
||||
"אזהרה: זה ינקה רק את הקבצים שלא אופליין (כלומר רק הורדות חיצוניות)",
|
||||
"Please check your connection and try again later...":
|
||||
"בבקשה בדוק את חיבור הרשת שלך ונסה שוב מאוחר יותר...",
|
||||
"Show more": "הראה עוד",
|
||||
"Importer": "מייבא רשימות השמעה",
|
||||
"Currently supporting only Spotify, with 100 tracks limit":
|
||||
"כרגע תומך רק בספוטיפיי, עם הגבלה של 100 שירים",
|
||||
"Due to API limitations": "בגלל מגבלות ה- API",
|
||||
"Enter your playlist link below": "הכנס את קישור רשימת ההשמעה שלך למטה",
|
||||
"Error loading URL!": "שגיאה בטעינת הקישור!",
|
||||
"Convert": "המר",
|
||||
"Download only": "הורד",
|
||||
"Downloading is currently stopped, click here to resume.":
|
||||
"ההורדה כרגע מושהית, לחץ כאן להמשיך.",
|
||||
"Tracks": "שירים",
|
||||
"Albums": "אלבומים",
|
||||
"Artists": "אומנים",
|
||||
"Playlists": "רשימות השמעה",
|
||||
"Import": "יבא",
|
||||
"Import playlists from Spotify": "יבא רשימת השמעה מספוטיפיי",
|
||||
"Statistics": "סטטיסטיקה",
|
||||
"Offline tracks": "שירים לא מקוונים",
|
||||
"Offline albums": "אלבומים לא מקוונים",
|
||||
"Offline playlists": "רשימות השמעה לא מקוונות",
|
||||
"Offline size": "גודל קבצים לא מקוונים",
|
||||
"Free space": "מקום פנוי",
|
||||
"Loved tracks": "שירים אהובים",
|
||||
"Favorites": "מועדפים",
|
||||
"All offline tracks": "כל השירים הלא מקוונים",
|
||||
"Create new playlist": "צור רשימת השמעה חדשה",
|
||||
"Cannot create playlists in offline mode":
|
||||
"לא יכול ליצור רשימת השמעה במצב אופליין",
|
||||
"Error": "שגיאה",
|
||||
"Error logging in! Please check your token and internet connection and try again.":
|
||||
"שגיאה בהתחברות! בדוק בבקשה את הטוקן שלך או את חיבור האינטרנט שלך ונסה שוב.",
|
||||
"Dismiss": "התעלם",
|
||||
"Welcome to": "ברוך הבא ל",
|
||||
"Please login using your Deezer account.":
|
||||
"בבקשה התחבר עם חשבון הדיזר שלך.",
|
||||
"Login using browser": "התחבר דרך הדפדפן",
|
||||
"Login using token": "התחבר דרך טוקן",
|
||||
"Enter ARL": "הכנס טוקן",
|
||||
"Token (ARL)": "טוקן (קישור אישי)",
|
||||
"Save": "שמור",
|
||||
"If you don't have account, you can register on deezer.com for free.":
|
||||
"לאם אין לך חשבון, אתה יכול להירשם ב deezer.com בחינם.",
|
||||
"Open in browser": "פתח בדפדפן",
|
||||
"By using this app, you don't agree with the Deezer ToS":
|
||||
"באמצעות שימוש ביישום הזה, אתה לא מסכים עם התנאים של דיזר",
|
||||
"Play next": "נגן הבא בתור",
|
||||
"Add to queue": "הוסף לתור",
|
||||
"Add track to favorites": "הוסף שיר למועדפים",
|
||||
"Add to playlist": "הוסף לרשימת השמעה",
|
||||
"Select playlist": "בחר רשימת השמעה",
|
||||
"Track added to": "שיר נוסף ל",
|
||||
"Remove from playlist": "הסר מרשימת השמעה",
|
||||
"Track removed from": "שיר הוסר מ",
|
||||
"Remove favorite": "הסר מועדף",
|
||||
"Track removed from library": "השיר הוסר מהסיפרייה",
|
||||
"Go to": "לך ל",
|
||||
"Make offline": "הורד לשימוש לא מקוון",
|
||||
"Add to library": "הוסף לספריה",
|
||||
"Remove album": "הסר אלבום",
|
||||
"Album removed": "אלבום הוסר",
|
||||
"Remove from favorites": "הסר מהמועדפים",
|
||||
"Artist removed from library": "אמן הוסר מהסיפרייה",
|
||||
"Add to favorites": "הוסף למועדפים",
|
||||
"Remove from library": "הסר מהסיפרייה",
|
||||
"Add playlist to library": "הוסף רשימת השמעה לסיפרייה",
|
||||
"Added playlist to library": "רשימת השמעה נוספה לסיפרייה",
|
||||
"Make playlist offline": "צור רשימת השמעה לא מקוונת",
|
||||
"Download playlist": "הורד רשימת השמעה",
|
||||
"Create playlist": "צור רשימת המעה",
|
||||
"Title": "שם",
|
||||
"Description": "תיאור",
|
||||
"Private": "פרטי",
|
||||
"Collaborative": "שיתופי פעולה",
|
||||
"Create": "צור",
|
||||
"Playlist created!": "רשימת השמעה נוצרה!",
|
||||
"Playing from:": "מנגן מ:",
|
||||
"Queue": "תור",
|
||||
"Offline search": "חיפוש אופליין",
|
||||
"Search Results": "תוצאות חיפוש",
|
||||
"No results!": "אין תוצאות!",
|
||||
"Show all tracks": "הראה את כל השירים",
|
||||
"Show all playlists": "הראה את כל רשימות ההשמעה",
|
||||
"Settings": "הגדרות",
|
||||
"General": "כללי",
|
||||
"Appearance": "מראה",
|
||||
"Quality": "איכות",
|
||||
"Deezer": "דיזר",
|
||||
"Theme": "ערכת נושא",
|
||||
"Currently": "בשימוש כרגע",
|
||||
"Select theme": "בחר ערכת נושא",
|
||||
"Light (default)": "בהיר (ברירת מחדח)",
|
||||
"Dark": "כהה",
|
||||
"Black (AMOLED)": "שחור (אמולד)",
|
||||
"Deezer (Dark)": "דיזר (כהה)",
|
||||
"Primary color": "צבע ראשי",
|
||||
"Selected color": "בחר צבע",
|
||||
"Use album art primary color": "השתמש בצבע ראשי של תמונת האלבום",
|
||||
"Warning: might be buggy": "אזהרה: יכול להיות באגים",
|
||||
"Mobile streaming": "הזרמת רשת סלולרית",
|
||||
"Wifi streaming": "הזרמת רשת אלחוטית",
|
||||
"External downloads": "הורדות חיצוניות",
|
||||
"Content language": "שפת תוכן",
|
||||
"Not app language, used in headers. Now":
|
||||
"לא שפת היישום, שימוש בכותרות. עכשיו",
|
||||
"Select language": "בחר שפה",
|
||||
"Content country": "מדינת תוכן",
|
||||
"Country used in headers. Now": "מדינה שמוצגת בכותרות. עכשיו",
|
||||
"Log tracks": "לוג שמיעת שירים",
|
||||
"Send track listen logs to Deezer, enable it for features like Flow to work properly":
|
||||
"שלח לוגים של השמעה לדיזר, הפעל מצב זה כדי שתכונות כמו flow יעבדו טוב",
|
||||
"Offline mode": "מצב אופליין",
|
||||
"Will be overwritten on start.": "יוחלף בהפעלה.",
|
||||
"Error logging in, check your internet connections.":
|
||||
"שגיאה בהתחברות, בדוק את חיבור הרשת שלך.",
|
||||
"Logging in...": "מתחבר...",
|
||||
"Download path": "נתיב הורדה",
|
||||
"Downloads naming": "שינוי שם בהורדה",
|
||||
"Downloaded tracks filename": "שם קבצי שירים בהורדה",
|
||||
"Valid variables are": "האפשרויות המוצעות הם",
|
||||
"Reset": "אתחל",
|
||||
"Clear": "נקה",
|
||||
"Create folders for artist": "צור תיקייה לאמנים",
|
||||
"Create folders for albums": "צור תיקייה לאלבומים",
|
||||
"Separate albums by discs": "חלק אלבומים לפי דיסקים",
|
||||
"Overwrite already downloaded files": "החלף קבצים שכבר הורדו",
|
||||
"Copy ARL": "העתק טוקן",
|
||||
"Copy userToken/ARL Cookie for use in other apps.":
|
||||
"העתק את הטוקן לשימוש בישומים אחרים.",
|
||||
"Copied": "הועתק",
|
||||
"Log out": "התנתק",
|
||||
"Due to plugin incompatibility, login using browser is unavailable without restart.":
|
||||
"בגלל אי התאמת התוסף, ההתחברות באמצעות הדפדפן אינה זמינה ללא הפעלה מחדש.",
|
||||
"(ARL only) Continue": "(טוקן בלבד) המשך",
|
||||
"Log out & Exit": "התנתק וצא",
|
||||
"Pick-a-Path": "בחר נתיב",
|
||||
"Select storage": "בחר אחסון",
|
||||
"Go up": "עלה למעלה",
|
||||
"Permission denied": "הרשאה נדחתה",
|
||||
"Language": "שפה",
|
||||
"Language changed, please restart Freezer to apply!":
|
||||
"שפה שונתה, בבקשה הפעל מחדש את Freezer כדי להחיל!",
|
||||
"Importing...": "מייבא...",
|
||||
"Radio": "רדיו",
|
||||
"Flow": "Flow",
|
||||
"Track is not available on Deezer!": "שיר לא קיים בדיזר!",
|
||||
"Failed to download track! Please restart.": "הורדת השיר נכשלה! התחל מחדש."
|
||||
}
|
||||
};
|
|
@ -0,0 +1,195 @@
|
|||
/*
|
||||
|
||||
Translated by: Shazzaam
|
||||
|
||||
*/
|
||||
|
||||
const language_hr_hr = {
|
||||
"hr_hr": {
|
||||
"Home": "Početna",
|
||||
"Search": "Tražilica",
|
||||
"Library": "Biblioteka",
|
||||
"Offline mode, can't play flow or smart track lists.":
|
||||
"Izvanmrežični način, ne može se reproducirati flow ili pametni popis pjesama",
|
||||
"Added to library": "Dodano u biblioteku",
|
||||
"Download": "Skini",
|
||||
"Disk": "Disk",
|
||||
"Offline": "Izvranmrežno",
|
||||
"Top Tracks": "Top Pjesme",
|
||||
"Show more tracks": "Prikaži više pjesama",
|
||||
"Top": "Top",
|
||||
"Top Albums": "Top Albumi",
|
||||
"Show all albums": "Prikaži više albuma",
|
||||
"Discography": "Diskografija",
|
||||
"Default": "Zadano",
|
||||
"Reverse": "Obrnuto",
|
||||
"Alphabetic": "Abecedno",
|
||||
"Artist": "Umjetnik",
|
||||
"Post processing...": "Naknadna obrada...",
|
||||
"Done": "Gotovo",
|
||||
"Delete": "Izbriši",
|
||||
"Are you sure you want to delete this download?":
|
||||
"Jeste li sigurni da želite izbrisati ovo skidanje?",
|
||||
"Cancel": "Poništi",
|
||||
"Downloads": "Skidanja",
|
||||
"Clear queue": "Očisti red",
|
||||
"This won't delete currently downloading item":
|
||||
"Ovo neće izbrisati stavku koja se trenutno skida ",
|
||||
"Are you sure you want to delete all queued downloads?":
|
||||
"Jeste li sigurni da želite da poništite sva skidanja u redu čekanja",
|
||||
"Clear downloads history": "Očisti povijest skidanja",
|
||||
"WARNING: This will only clear non-offline (external downloads)":
|
||||
"UPOZORENJE: Ovo će ukloniti samo izvanmrežna (vanjska) skidanja",
|
||||
"Please check your connection and try again later...":
|
||||
"Molimo vas da provjerite vašu konekciju i da pokušate ponovno...",
|
||||
"Show more": "Pokaži više",
|
||||
"Importer": "Uvoznik",
|
||||
"Currently supporting only Spotify, with 100 tracks limit":
|
||||
"Trenutno podržava samo Spotify, sa limitom od 100 pjesama",
|
||||
"Due to API limitations": "Zbog ograničenja API-a",
|
||||
"Enter your playlist link below":
|
||||
"Unesite vezu od vašeg popisa za reprodukciju ispod",
|
||||
"Error loading URL!": "Pogreška pri učitavanju URL-a!",
|
||||
"Convert": "Pretvori",
|
||||
"Download only": "Samo skidanja",
|
||||
"Downloading is currently stopped, click here to resume.":
|
||||
"Skidanja su trenutno zaustavljena, kliknite ovdje da se nastave.",
|
||||
"Tracks": "Pjesme",
|
||||
"Albums": "Albumi",
|
||||
"Artists": "Umjetnici",
|
||||
"Playlists": "Popisi za reprodukciju",
|
||||
"Import": "Uvezi",
|
||||
"Import playlists from Spotify": "Uvezi popis za reprodukciju sa Spotify-a",
|
||||
"Statistics": "Statistike",
|
||||
"Offline tracks": "Izvanmrežične pjesme",
|
||||
"Offline albums": "Izvanmrežični albumi",
|
||||
"Offline playlists": "Izvanmrežični popisi za reprodukciju",
|
||||
"Offline size": "Izvanmrežična veličina",
|
||||
"Free space": "Slobodno mjesto",
|
||||
"Loved tracks": "Voljene pjesme",
|
||||
"Favorites": "Favoriti",
|
||||
"All offline tracks": "Sve izvanmrežične pjesme",
|
||||
"Create new playlist": "Kreirajte novi popis za reprodukciju",
|
||||
"Cannot create playlists in offline mode":
|
||||
"Nije moguće napraviti popis za reprodukciju u izvanmrežnom načinu",
|
||||
"Error": "Pogreška",
|
||||
"Error logging in! Please check your token and internet connection and try again.":
|
||||
"Pogreška pri prijavljivanju! Molimo vas da provjerite token i internet konekciju i da pokušate ponovno.",
|
||||
"Dismiss": "Odbaciti",
|
||||
"Welcome to": "Dobrodošli u",
|
||||
"Please login using your Deezer account.":
|
||||
"Molimo vas da se prijavite pomoću vašeg Deezer računa.",
|
||||
"Login using browser": "Prijava pomoću preglednika",
|
||||
"Login using token": "Prijava pomoću tokena",
|
||||
"Enter ARL": "Upišite ARL",
|
||||
"Token (ARL)": "Token (ARL)",
|
||||
"Save": "Spremi",
|
||||
"If you don't have account, you can register on deezer.com for free.":
|
||||
"Ako nemate račun, možete se besplatno registrirati na deezer.com.",
|
||||
"Open in browser": "Otvori u pregledniku",
|
||||
"By using this app, you don't agree with the Deezer ToS":
|
||||
"Korištenjem ove aplikacije, ne slažete se sa Deezer Uvjetima pružanja usluge",
|
||||
"Play next": "Pokreni sljedeću",
|
||||
"Add to queue": "Dodaj u red ",
|
||||
"Add track to favorites": "Dodaj pjesmu u omiljene",
|
||||
"Add to playlist": "Dodaj u popis za reprodukciju",
|
||||
"Select playlist": "Izaberi popis za reprodukciju",
|
||||
"Track added to": "Pjesma je dodana u",
|
||||
"Remove from playlist": "Ukloni iz popisa za reprodukciju",
|
||||
"Track removed from": "Pjesma je uklonjena iz",
|
||||
"Remove favorite": "Uklonite omiljenu",
|
||||
"Track removed from library": "Pjesma je uklonjena iz biblioteke",
|
||||
"Go to": "Idi u",
|
||||
"Make offline": "Postavi izvanmrežno",
|
||||
"Add to library": "Dodaj u biblioteku",
|
||||
"Remove album": "Ukloni album",
|
||||
"Album removed": "Album uklonjen",
|
||||
"Remove from favorites": "Ukloni iz omiljenih",
|
||||
"Artist removed from library": "Umjetnik je uklonjen iz biblioteke",
|
||||
"Add to favorites": "Dodaj u omiljene",
|
||||
"Remove from library": "Ukloni iz biblioteke",
|
||||
"Add playlist to library": "Dodaj popis za reprodukciju u biblioteku",
|
||||
"Added playlist to library": "Popis za reprodukciju je dodan u biblioteku",
|
||||
"Make playlist offline": "Napravi popis za reprodukciju izvanmrežan.",
|
||||
"Download playlist": "Skini popis za reprodukciju",
|
||||
"Create playlist": "Napravi popis za reprodukciju",
|
||||
"Title": "Naslov",
|
||||
"Description": "Opis",
|
||||
"Private": "Privatno",
|
||||
"Collaborative": "Suradnički",
|
||||
"Create": "Napravi",
|
||||
"Playlist created!": "Popis za reprodukciju je napravljen!",
|
||||
"Playing from:": "Svira iz:",
|
||||
"Queue": "Red",
|
||||
"Offline search": "Izvanmrežno traženje",
|
||||
"Search Results": "Rezultati traženja",
|
||||
"No results!": "Nema rezultata!",
|
||||
"Show all tracks": "Prikaži sve pjesme!",
|
||||
"Show all playlists": "Prikaži sve popise za reprodukciju",
|
||||
"Settings": "Postavke",
|
||||
"General": "Općenito",
|
||||
"Appearance": "Izgled",
|
||||
"Quality": "Kvalitet",
|
||||
"Deezer": "Deezer",
|
||||
"Theme": "Tema",
|
||||
"Currently": "Trenutno",
|
||||
"Select theme": "Izaberi temu",
|
||||
"Light (default)": "Svijetla (Zadano)",
|
||||
"Dark": "Mračno",
|
||||
"Black (AMOLED)": "Crno (AMOLED)",
|
||||
"Deezer (Dark)": "Deezer (Mračno)",
|
||||
"Primary color": "Primarna boja",
|
||||
"Selected color": "Izabrana boja",
|
||||
"Use album art primary color": "Koristi primarnu boju slike albuma",
|
||||
"Warning: might be buggy": "Upozorenje: može biti bugovito",
|
||||
"Mobile streaming": "Strimovanje preko mobilnih podataka",
|
||||
"Wifi streaming": "Strimovanje preko wifi-a",
|
||||
"External downloads": "Vanjska skidanja",
|
||||
"Content language": "Jezik skidanja",
|
||||
"Not app language, used in headers. Now":
|
||||
"Nije jezik aplikacije, korišteno u zaglavjima.",
|
||||
"Select language": "Izaberi jezik",
|
||||
"Content country": "Zemlja sadržaja",
|
||||
"Country used in headers. Now": "Zemlja korištena u zaglavjima. Sad",
|
||||
"Log tracks": "Zapis traka",
|
||||
"Send track listen logs to Deezer, enable it for features like Flow to work properly":
|
||||
"Šalji zapisnike slušanja pjesama Deezeru, omogućite za mogućnosti kao Flow da rade ispravno",
|
||||
"Offline mode": "Izvanmrežični način",
|
||||
"Will be overwritten on start.": "Biti će napisano preko na početku.",
|
||||
"Error logging in, check your internet connections.":
|
||||
"Pogreška prilikom prijavljivanja, molimo vas da provjerite vašu internet konekciju.",
|
||||
"Logging in...": "Prijavljivanje...",
|
||||
"Download path": "Mjesto za skidanja",
|
||||
"Downloads naming": "Imenovanja skidanja",
|
||||
"Downloaded tracks filename": "Naziv datoteka skinutih pjesama",
|
||||
"Valid variables are": "Važeće varijable su",
|
||||
"Reset": "Resetiraj",
|
||||
"Clear": "Očisti",
|
||||
"Create folders for artist": "Napravi datoteke za umjetnike",
|
||||
"Create folders for albums": "Napravi datoteke za albume",
|
||||
"Separate albums by discs": "Odvoji albume od diskova",
|
||||
"Overwrite already downloaded files": "Napiši preko već skinutih datoteka",
|
||||
"Copy ARL": "Kopiraj ARL",
|
||||
"Copy userToken/ARL Cookie for use in other apps.":
|
||||
"Kopiraj userToken/ARL cookie za korištenje u drugim aplikacijama.",
|
||||
"Copied": "Kopirano",
|
||||
"Log out": "Odjavi se",
|
||||
"Due to plugin incompatibility, login using browser is unavailable without restart.":
|
||||
"Zbog nekompatibilnosti dodataka, prijava putem preglednika nije dostupna bez ponovnog pokretanja.",
|
||||
"(ARL ONLY) Continue": "(SAMO ARL) Nastavi",
|
||||
"Log out & Exit": "Odjavi se i izađi",
|
||||
"Pick-a-Path": "Izaberi mjesto",
|
||||
"Select storage": "Izaberi skladište",
|
||||
"Go up": "Idi gore",
|
||||
"Permission denied": "Dozvola odbijena",
|
||||
"Language": "Jezik",
|
||||
"Language changed, please restart Freezer to apply!":
|
||||
"Jezik je promjenjen, molimo vas da ponovno pokrenete Freezer da se promjene primjene.",
|
||||
"Importing...": "Uvoženje...",
|
||||
"Radio": "Radio",
|
||||
"Flow": "Flow",
|
||||
"Track is not available on Deezer!": "Pjesma nije dostupna na Deezeru!",
|
||||
"Failed to download track! Please restart.":
|
||||
"Preuzimanje pjesme nije uspjelo! Molimo vas da ponovno pokrenite."
|
||||
}
|
||||
};
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
|
||||
Translated by: koreezzz
|
||||
|
||||
*/
|
||||
|
||||
const language_ko_ko = {
|
||||
"ko_ko": {
|
||||
"Home": "홈",
|
||||
"Search": "검색",
|
||||
"Library": "라이브러리",
|
||||
"Offline mode, can't play flow or smart track lists.":
|
||||
"오프라인 모드. Flow 또는 스마트 트랙 목록을 재생할 수 없습니다.",
|
||||
"Added to library": "라이브러리에 추가됨",
|
||||
"Download": "다운로드",
|
||||
"Disk": "디스크",
|
||||
"Offline": "오프라인",
|
||||
"Top Tracks": "인기 트랙",
|
||||
"Show more tracks": "더 많은 트랙보기",
|
||||
"Top": "인기",
|
||||
"Top Albums": "인기 앨범",
|
||||
"Show all albums": "모든 앨범보기",
|
||||
"Discography": "디스코그래피",
|
||||
"Default": "기본값",
|
||||
"Reverse": "역전",
|
||||
"Alphabetic": "알파벳순",
|
||||
"Artist": "가수",
|
||||
"Post processing...": "후 처리…",
|
||||
"Done": "완료",
|
||||
"Delete": "삭제",
|
||||
"Are you sure you want to delete this download?": "이 다운로드를 삭제 하시겠습니까?",
|
||||
"Cancel": "취소",
|
||||
"Downloads": "다운로드한 내용",
|
||||
"Clear queue": "목록 지우기",
|
||||
"This won't delete currently downloading item": "현재 다운로드중인 항목은 삭제되지 않습니다.",
|
||||
"Are you sure you want to delete all queued downloads?":
|
||||
"대기중인 모든 다운로드를 삭제 하시겠습니까?",
|
||||
"Clear downloads history": "다운로드 기록 지우기",
|
||||
"WARNING: This will only clear non-offline (external downloads)":
|
||||
"경고 : 오프라인이 아닌 내용만 삭제됩니다 (외부 다운로드).",
|
||||
"Please check your connection and try again later...":
|
||||
"인터넷 연결을 확인하고 나중에 다시 시도하십시오 ...",
|
||||
"Show more": "자세히보기",
|
||||
"Importer": "수입자",
|
||||
"Currently supporting only Spotify, with 100 tracks limit":
|
||||
"현재 Spotify 만 지원하며 트랙 제한은 100 곡입니다.",
|
||||
"Due to API limitations": "API 제한으로 인해",
|
||||
"Enter your playlist link below": "아래에 곡목표 링크 입력 하십시오",
|
||||
"Error loading URL!": "URL 불러 오기 오류!",
|
||||
"Convert": "변환",
|
||||
"Download only": "다운로드 전용",
|
||||
"Downloading is currently stopped, click here to resume.":
|
||||
"다운로드는 현재 중지되었습니다. 다시 시작하려면 여기를 클릭하십시오.",
|
||||
"Tracks": "트랙",
|
||||
"Albums": "앨범",
|
||||
"Artists": "가수",
|
||||
"Playlists": "재생 목록",
|
||||
"Import": "수입",
|
||||
"Import playlists from Spotify": "Spotify에서 재생 목록을 가져 오기",
|
||||
"Statistics": "통계",
|
||||
"Offline tracks": "오프라인 트랙",
|
||||
"Offline albums": "오프라인 앨범",
|
||||
"Offline playlists": "오프라인 재생 목록",
|
||||
"Offline size": "오프라인 사이즈",
|
||||
"Free space": "자유 공간",
|
||||
"Loved tracks": "즐겨 찾기는 트랙",
|
||||
"Favorites": "즐겨 찾기",
|
||||
"All offline tracks": "모든 오프라인 트랙",
|
||||
"Create new playlist": "새 재생 목록을 만들기",
|
||||
"Cannot create playlists in offline mode": "오프라인 모드에서 재생 목록을 만들 수 없습니다.",
|
||||
"Error": "오류",
|
||||
"Error logging in! Please check your token and internet connection and try again.":
|
||||
"로그인 오류! 토큰 및 인터넷 연결을 확인하고 다시 시도하십시오.",
|
||||
"Dismiss": "해고",
|
||||
"Welcome to": "\$에 오신 것을 환영합니다",
|
||||
"Please login using your Deezer account.": "Deezer 계정을 사용하여 로그인하십시오.",
|
||||
"Login using browser": "브라우저를 사용하여 로그인",
|
||||
"Login using token": "토큰을 사용하여 로그인",
|
||||
"Enter ARL": "ARL 입력",
|
||||
"Token (ARL)": "토큰 (ARL)",
|
||||
"Save": "저장",
|
||||
"If you don't have account, you can register on deezer.com for free.":
|
||||
"계정이 없으시면 deezer.com에서 무료로 등록하실 수 있습니다.",
|
||||
"Open in browser": "브라우저에서 열기",
|
||||
"By using this app, you don't agree with the Deezer ToS":
|
||||
"이 앱을 사용하면 Deezer ToS에 동의하지 않습니다.",
|
||||
"Play next": "다음 재생",
|
||||
"Add to queue": "목록에 추가",
|
||||
"Add track to favorites": "즐겨 찾기에 트랙 추가",
|
||||
"Add to playlist": "재생 목록에 추가",
|
||||
"Select playlist": "재생 목록을 선택",
|
||||
"Track added to": "\$에 트랙을 추가되었습니다",
|
||||
"Remove from playlist": "재생 목록에서 삭제",
|
||||
"Track removed from": "\$에서 트랙을 삭제되었습니다",
|
||||
"Remove favorite": "즐겨 찾기를 삭제",
|
||||
"Track removed from library": "라이브러리에서 트랙을 삭제되었습니다",
|
||||
"Go to": "\$에 이동",
|
||||
"Make offline": "오프라인으로 설정",
|
||||
"Add to library": "라이브러리에 추가",
|
||||
"Remove album": "앨범을 삭제",
|
||||
"Album removed": "앨범을 삭제되었습니다",
|
||||
"Remove from favorites": "즐겨 찾기에서 삭제",
|
||||
"Artist removed from library": "가수를 라이브러리에서 삭제되었습니다.",
|
||||
"Add to favorites": "즐겨 찾기에 추가",
|
||||
"Remove from library": "라이브러리에서 삭제",
|
||||
"Add playlist to library": "라이브러리에 재생 목록을 추가",
|
||||
"Added playlist to library": "라이브러리에 재생 목록을 추가되었습니다",
|
||||
"Make playlist offline": "재생 목록을 오프라인으로 설정",
|
||||
"Download playlist": "재생 목록을 다운로드",
|
||||
"Create playlist": "재생 목록을 만들기",
|
||||
"Title": "타이틀",
|
||||
"Description": "서술",
|
||||
"Private": "사유의",
|
||||
"Collaborative": "공동의",
|
||||
"Create": "창조",
|
||||
"Playlist created!": "재생 목록을 생성되었습니다!",
|
||||
"Playing from:": "\$부터 재생:",
|
||||
"Queue": "목록",
|
||||
"Offline search": "오프라인 검색",
|
||||
"Search Results": "검색 결과",
|
||||
"No results!": "결과가 없습니다!",
|
||||
"Show all tracks": "모든 트랙을 보기",
|
||||
"Show all playlists": "모든 재생 목록을 보기",
|
||||
"Settings": "설정",
|
||||
"General": "일반",
|
||||
"Appearance": "외모",
|
||||
"Quality": "품질",
|
||||
"Deezer": "Deezer",
|
||||
"Theme": "테마",
|
||||
"Currently": "현재",
|
||||
"Select theme": "테마 선택",
|
||||
"Light (default)": "라이트 (기본값)",
|
||||
"Dark": "다크",
|
||||
"Black (AMOLED)": "블랙 (AMOLED)",
|
||||
"Deezer (Dark)": "Deezer (다크)",
|
||||
"Primary color": "원색",
|
||||
"Selected color": "선택한 색상",
|
||||
"Use album art primary color": "앨범 아트 기본 색상 사용",
|
||||
"Warning: might be buggy": "경고: 버그가 있을 수 있습니다.",
|
||||
"Mobile streaming": "모바일 스트리밍",
|
||||
"Wifi streaming": "Wi-Fi 스트리밍",
|
||||
"External downloads": "외부 다운로드",
|
||||
"Content language": "콘텐츠 언어",
|
||||
"Not app language, used in headers. Now": "헤더에 사용된 앱 언어가 아닙니다. 현재",
|
||||
"Select language": "언어 선택",
|
||||
"Content country": "콘텐츠 국가",
|
||||
"Country used in headers. Now": "헤더에 사용 된 국가. 현재",
|
||||
"Log tracks": "트랙로그",
|
||||
"Send track listen logs to Deezer, enable it for features like Flow to work properly":
|
||||
"Deezer에 트랙로그를 전송. Flow와 같은 기능이 제대로 작동하려면 이 기능을 활성화하십시오.",
|
||||
"Offline mode": "오프라인 모드",
|
||||
"Will be overwritten on start.": "시작할 때 덮어 씁니다.",
|
||||
"Error logging in, check your internet connections.":
|
||||
"로그인 오류, 인터넷 연결을 확인하십시오.",
|
||||
"Logging in...": "…\$에로그인 중",
|
||||
"Download path": "다운로드 경로",
|
||||
"Downloads naming": "다운로드 네이밍",
|
||||
"Downloaded tracks filename": "다운로드 된 트랙 파일명",
|
||||
"Valid variables are": "유효한 변수",
|
||||
"Reset": "초기화",
|
||||
"Clear": "치우기",
|
||||
"Create folders for artist": "가수 용 폴더 만들기",
|
||||
"Create folders for albums": "앨범 용 폴더 만들기",
|
||||
"Separate albums by discs": "디스크별로 앨범 분리",
|
||||
"Overwrite already downloaded files": "이미 다운로드 한 파일을 덮어 쓰기",
|
||||
"Copy ARL": "ARL 복사",
|
||||
"Copy userToken/ARL Cookie for use in other apps.":
|
||||
"다른 앱에서 사용하기 위해 사용자 토큰 / ARL 쿠키를 복사하기.",
|
||||
"Copied": "복사 됨",
|
||||
"Log out": "로그 아웃",
|
||||
"Due to plugin incompatibility, login using browser is unavailable without restart.":
|
||||
"플러그인 비 호환성으로 인해 다시 시작하지 않으면 브라우저를 사용하여 로그인 할 수 없습니다.",
|
||||
"(ARL ONLY) Continue": "(ARL 만 해당) 계속",
|
||||
"Log out & Exit": "로그 아웃 및 종료",
|
||||
"Pick-a-Path": "경로 선택",
|
||||
"Select storage": "저장소 선택",
|
||||
"Go up": "위로 이동",
|
||||
"Permission denied": "권한이 거부되었습니다.",
|
||||
"Language": "언어",
|
||||
"Language changed, please restart Freezer to apply!":
|
||||
"언어가 변경되었습니다. 적용하려면 Freezer를 다시 시작하세요!",
|
||||
"Importing...": "…\$가져 오는 중",
|
||||
"Radio": "라디오",
|
||||
"Flow": "Flow",
|
||||
"Track is not available on Deezer!": "Deezer에서는 트랙을 사용할 수 없습니다!",
|
||||
"Failed to download track! Please restart.": "트랙을 다운로드하지 못했습니다! 다시 시작하십시오.",
|
||||
}
|
||||
};
|
|
@ -9,12 +9,11 @@ const language_ru_ru = {
|
|||
"Home": "Главная",
|
||||
"Search": "Поиск",
|
||||
"Library": "Библиотека",
|
||||
"Offline mode, can't play flow or smart track lists.":
|
||||
"Автономный режим, нельзя воспроизводить потоки или умные списки треков.",
|
||||
"Offline mode, can't play flow or smart track lists.": "Офлайн режим, нельзя воспроизводить потоки или умные списки треков.",
|
||||
"Added to library": "Добавить в библиотеку",
|
||||
"Download": "Скачать",
|
||||
"Disk": "Disk",
|
||||
"Offline": "Офлайн",
|
||||
"Disk": "Диск",
|
||||
"Offline": "Скачанные треки",
|
||||
"Top Tracks": "Лучшие треки",
|
||||
"Show more tracks": "Показать больше треков",
|
||||
"Top": "Top",
|
||||
|
@ -24,17 +23,16 @@ const language_ru_ru = {
|
|||
"Default": "По умолчанию",
|
||||
"Reverse": "Обратный",
|
||||
"Alphabetic": "По алфавиту",
|
||||
"Artist": "Артист",
|
||||
"Artist": "Исполнитель",
|
||||
"Post processing...": "Постобработка...",
|
||||
"Done": "Готово",
|
||||
"Delete": "Удалить",
|
||||
"Are you sure you want to delete this download?":
|
||||
"Вы действительно хотите удалить эту загрузку??",
|
||||
"Вы действительно хотите удалить эту загрузку?",
|
||||
"Cancel": "Отмена",
|
||||
"Downloads": "Загрузки",
|
||||
"Clear queue": "Очистить очередь",
|
||||
"This won't delete currently downloading item":
|
||||
"Это не удалит загружаемый в данный момент элемент",
|
||||
"This won't delete currently downloading item": "Это не удалит загружаемый в данный момент элемент",
|
||||
"Are you sure you want to delete all queued downloads?":
|
||||
"Вы действительно хотите удалить все загрузки в очереди?",
|
||||
"Clear downloads history": "Очистить историю загрузок",
|
||||
|
@ -44,20 +42,18 @@ const language_ru_ru = {
|
|||
"Пожалуйста, проверьте ваше соединение и повторите попытку позже...",
|
||||
"Show more": "Показать больше",
|
||||
"Importer": "Импортер",
|
||||
"Currently supporting only Spotify, with 100 tracks limit":
|
||||
"В настоящее время поддерживается только Spotify с ограничением 100 треков",
|
||||
"Currently supporting only Spotify, with 100 tracks limit": "В настоящее время поддерживается только Spotify, с ограничением 100 треков",
|
||||
"Due to API limitations": "Из-за ограничений API",
|
||||
"Enter your playlist link below": "Введите ссылку на свой плейлист ниже",
|
||||
"Error loading URL!": "Ошибка загрузки URL!",
|
||||
"Convert": "Перерабатывать",
|
||||
"Download only": "Только скачиные",
|
||||
"Downloading is currently stopped, click here to resume.":
|
||||
"В настоящее время загрузка остановлена, нажмите здесь, чтобы возобновить.",
|
||||
"Download only": "Только скачанные",
|
||||
"Downloading is currently stopped, click here to resume.": "В настоящее время загрузка остановлена, нажмите здесь, чтобы возобновить.",
|
||||
"Tracks": "Треки",
|
||||
"Albums": "Альбомы",
|
||||
"Artists": "Артисты",
|
||||
"Playlists": "Плейлисты",
|
||||
"Import": "Import",
|
||||
"Import": "Импорт",
|
||||
"Import playlists from Spotify": "Импортировать плейлисты из Spotify",
|
||||
"Statistics": "Статистика",
|
||||
"Offline tracks": "Автономные треки",
|
||||
|
@ -67,10 +63,9 @@ const language_ru_ru = {
|
|||
"Free space": "Свободное место",
|
||||
"Loved tracks": "Любимые треки",
|
||||
"Favorites": "Избранное",
|
||||
"All offline tracks": "Все оффлайн треки",
|
||||
"All offline tracks": "Скачанные треки",
|
||||
"Create new playlist": "Создать новый плейлист",
|
||||
"Cannot create playlists in offline mode":
|
||||
"Невозможно создавать плейлисты в автономном режиме",
|
||||
"Cannot create playlists in offline mode": "Невозможно создавать плейлисты в автономном режиме",
|
||||
"Error": "Ошибка",
|
||||
"Error logging in! Please check your token and internet connection and try again.":
|
||||
"Ошибка входа! Проверьте свой токен и подключение к Интернету и повторите попытку.",
|
||||
|
@ -127,39 +122,38 @@ const language_ru_ru = {
|
|||
"Show all playlists": "Показать все плейлисты",
|
||||
"Settings": "Настройки",
|
||||
"General": "Общее",
|
||||
"Appearance": "Внешность",
|
||||
"Quality": "Качественный",
|
||||
"Appearance": "Интерфейс",
|
||||
"Quality": "Качество звука",
|
||||
"Deezer": "Deezer",
|
||||
"Theme": "Тема",
|
||||
"Currently": "В настоящее время",
|
||||
"Currently": "Выбрана тема",
|
||||
"Select theme": "Выберите тему",
|
||||
"Light (default)": "Светлая (По умолчанию)",
|
||||
"Dark": "Темная",
|
||||
"Dark": "Dark (Темная тема)",
|
||||
"Black (AMOLED)": "Черная (AMOLED)",
|
||||
"Deezer (Dark)": "Deezer (Dark)",
|
||||
"Primary color": "Основной цвет",
|
||||
"Selected color": "Выбранный цвет",
|
||||
"Use album art primary color": "Использовать основной цвет обложки альбома",
|
||||
"Use album art primary color": "Использовать цвет обложки",
|
||||
"Warning: might be buggy": "Предупреждение: может быть ошибка",
|
||||
"Mobile streaming": "Мобильная трансляция",
|
||||
"Wifi streaming": "Wifi трансляция",
|
||||
"Mobile streaming": "Мобильная сеть",
|
||||
"Wifi streaming": "Wifi сеть",
|
||||
"External downloads": "Внешние загрузки",
|
||||
"Content language": "Язык содержания",
|
||||
"Not app language, used in headers. Now":
|
||||
"Не язык приложения, используемый в заголовках. Сейчас",
|
||||
"Not app language, used in headers. Now": "Используемый в заголовках. Сейчас",
|
||||
"Select language": "Выберите язык",
|
||||
"Content country": "Страна содержания",
|
||||
"Country used in headers. Now": "Страна, используемая в заголовках. Сейчас",
|
||||
"Log tracks": "Журнал треков",
|
||||
"Send track listen logs to Deezer, enable it for features like Flow to work properly":
|
||||
"Отправьте журналы прослушивания треков в Deezer, включите его, чтобы такие функции, как Flow, работали правильно",
|
||||
"Offline mode": "Автономный режим",
|
||||
"Offline mode": "Офлайн режим",
|
||||
"Will be overwritten on start.": "Будет перезаписан при запуске.",
|
||||
"Error logging in, check your internet connections.":
|
||||
"Ошибка при входе, проверьте свои интернет-соединения.",
|
||||
"Logging in...": "Происходит вход в систему...",
|
||||
"Download path": "Скачать путь",
|
||||
"Downloads naming": "Именование загрузок",
|
||||
"Download path": "Путь сохранения файлов",
|
||||
"Downloads naming": "Название при скачивании",
|
||||
"Downloaded tracks filename": "Имя файла загруженных треков",
|
||||
"Valid variables are": "Допустимые переменные:",
|
||||
"Reset": "Сброс",
|
||||
|
@ -181,7 +175,7 @@ const language_ru_ru = {
|
|||
"Select storage": "Выберите хранилище",
|
||||
"Go up": "Подниматься",
|
||||
"Permission denied": "Доступ запрещен",
|
||||
"Language": "Язык",
|
||||
"Language": "Язык приложения",
|
||||
"Language changed, please restart Freezer to apply!": "Язык изменен, перезапустите Freezer, чтобы применить!",
|
||||
"Importing...": "Импорт...",
|
||||
"Radio": "Радио"
|
||||
|
|
|
@ -2,7 +2,9 @@ import 'package:audio_service/audio_service.dart';
|
|||
import 'package:custom_navigator/custom_navigator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/ui/library.dart';
|
||||
import 'package:freezer/ui/login_screen.dart';
|
||||
import 'package:freezer/ui/search.dart';
|
||||
|
@ -28,8 +30,8 @@ void main() async {
|
|||
|
||||
//Initialize globals
|
||||
settings = await Settings().loadSettings();
|
||||
//await imagesDatabase.init();
|
||||
await downloadManager.init();
|
||||
cache = await Cache.load();
|
||||
|
||||
runApp(FreezerApp());
|
||||
}
|
||||
|
@ -108,7 +110,7 @@ class _LoginMainWrapperState extends State<LoginMainWrapper> {
|
|||
//Load token on background
|
||||
deezerAPI.arl = settings.arl;
|
||||
settings.offlineMode = true;
|
||||
deezerAPI.authorize().then((b) {
|
||||
deezerAPI.authorize().then((b) async {
|
||||
if (b) setState(() => settings.offlineMode = false);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:freezer/ui/cached_image.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
@ -51,6 +52,12 @@ class Settings {
|
|||
bool albumDiscFolder;
|
||||
@JsonKey(defaultValue: false)
|
||||
bool overwriteDownload;
|
||||
@JsonKey(defaultValue: 2)
|
||||
int downloadThreads;
|
||||
@JsonKey(defaultValue: false)
|
||||
bool playlistFolder;
|
||||
@JsonKey(defaultValue: true)
|
||||
bool downloadLyrics;
|
||||
|
||||
|
||||
//Appearance
|
||||
|
@ -76,6 +83,8 @@ class Settings {
|
|||
String deezerCountry;
|
||||
@JsonKey(defaultValue: false)
|
||||
bool logListen;
|
||||
@JsonKey(defaultValue: null)
|
||||
String proxyAddress;
|
||||
|
||||
Settings({this.downloadPath, this.arl});
|
||||
|
||||
|
@ -138,6 +147,14 @@ class Settings {
|
|||
return ThemeData();
|
||||
}
|
||||
|
||||
//JSON to forward into download service
|
||||
Map getServiceSettings() {
|
||||
return {
|
||||
"downloadThreads": downloadThreads,
|
||||
"overwriteDownload": overwriteDownload,
|
||||
"downloadLyrics": downloadLyrics
|
||||
};
|
||||
}
|
||||
|
||||
void updateUseArtColor(bool v) {
|
||||
useArtColor = v;
|
||||
|
@ -181,6 +198,7 @@ class Settings {
|
|||
Future save() async {
|
||||
File f = File(await getPath());
|
||||
await f.writeAsString(jsonEncode(this.toJson()));
|
||||
downloadManager.updateServiceSettings();
|
||||
}
|
||||
|
||||
Future updateAudioServiceQuality() async {
|
||||
|
|
|
@ -30,13 +30,17 @@ Settings _$SettingsFromJson(Map<String, dynamic> json) {
|
|||
..artistFolder = json['artistFolder'] as bool ?? true
|
||||
..albumDiscFolder = json['albumDiscFolder'] as bool ?? false
|
||||
..overwriteDownload = json['overwriteDownload'] as bool ?? false
|
||||
..downloadThreads = json['downloadThreads'] as int ?? 2
|
||||
..playlistFolder = json['playlistFolder'] as bool ?? false
|
||||
..downloadLyrics = json['downloadLyrics'] as bool ?? true
|
||||
..theme =
|
||||
_$enumDecodeNullable(_$ThemesEnumMap, json['theme']) ?? Themes.Light
|
||||
..primaryColor = Settings._colorFromJson(json['primaryColor'] as int)
|
||||
..useArtColor = json['useArtColor'] as bool ?? false
|
||||
..deezerLanguage = json['deezerLanguage'] as String ?? 'en'
|
||||
..deezerCountry = json['deezerCountry'] as String ?? 'US'
|
||||
..logListen = json['logListen'] as bool ?? false;
|
||||
..logListen = json['logListen'] as bool ?? false
|
||||
..proxyAddress = json['proxyAddress'] as String;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
||||
|
@ -52,12 +56,16 @@ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
|||
'artistFolder': instance.artistFolder,
|
||||
'albumDiscFolder': instance.albumDiscFolder,
|
||||
'overwriteDownload': instance.overwriteDownload,
|
||||
'downloadThreads': instance.downloadThreads,
|
||||
'playlistFolder': instance.playlistFolder,
|
||||
'downloadLyrics': instance.downloadLyrics,
|
||||
'theme': _$ThemesEnumMap[instance.theme],
|
||||
'primaryColor': Settings._colorToJson(instance.primaryColor),
|
||||
'useArtColor': instance.useArtColor,
|
||||
'deezerLanguage': instance.deezerLanguage,
|
||||
'deezerCountry': instance.deezerCountry,
|
||||
'logListen': instance.logListen,
|
||||
'proxyAddress': instance.proxyAddress,
|
||||
};
|
||||
|
||||
T _$enumDecode<T>(
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/languages/ar_ar.dart';
|
||||
import 'package:freezer/languages/de_de.dart';
|
||||
import 'package:freezer/languages/el_gr.dart';
|
||||
import 'package:freezer/languages/en_us.dart';
|
||||
import 'package:freezer/languages/es_es.dart';
|
||||
import 'package:freezer/languages/fil_ph.dart';
|
||||
import 'package:freezer/languages/fr_fr.dart';
|
||||
import 'package:freezer/languages/he_il.dart';
|
||||
import 'package:freezer/languages/hr_hr.dart';
|
||||
import 'package:freezer/languages/it_it.dart';
|
||||
import 'package:freezer/languages/ko_ko.dart';
|
||||
import 'package:freezer/languages/pt_br.dart';
|
||||
import 'package:freezer/languages/ru_ru.dart';
|
||||
import 'package:i18n_extension/i18n_extension.dart';
|
||||
|
@ -17,12 +22,19 @@ const supportedLocales = [
|
|||
const Locale('de', 'DE'),
|
||||
const Locale('ru', 'RU'),
|
||||
const Locale('es', 'ES'),
|
||||
const Locale('hr', 'HR'),
|
||||
const Locale('el', 'GR'),
|
||||
const Locale('ko', 'KO'),
|
||||
const Locale('fr', 'FR'),
|
||||
const Locale('he', 'IL'),
|
||||
const Locale('fil', 'PH')
|
||||
];
|
||||
|
||||
extension Localization on String {
|
||||
static var _t = Translations.byLocale("en_US") +
|
||||
language_en_us + language_ar_ar + language_pt_br + language_it_it + language_de_de + language_ru_ru + language_fil_ph + language_es_es;
|
||||
language_en_us + language_ar_ar + language_pt_br + language_it_it + language_de_de + language_ru_ru +
|
||||
language_fil_ph + language_es_es + language_el_gr + language_hr_hr + language_ko_ko + language_fr_fr +
|
||||
language_he_il;
|
||||
|
||||
String get i18n => localize(this, _t);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
|
@ -692,6 +695,22 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
}
|
||||
}
|
||||
|
||||
//Load cached playlist sorting
|
||||
void _restoreSort() async {
|
||||
if (cache.playlistSort == null) {
|
||||
cache.playlistSort = {};
|
||||
await cache.save();
|
||||
return;
|
||||
}
|
||||
if (cache.playlistSort[playlist.id] != null) {
|
||||
//Preload tracks
|
||||
if (playlist.tracks.length < playlist.trackCount) {
|
||||
playlist = await deezerAPI.fullPlaylist(playlist.id);
|
||||
}
|
||||
setState(() => _sort = cache.playlistSort[playlist.id]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
playlist = widget.playlist;
|
||||
|
@ -717,6 +736,8 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
});
|
||||
}
|
||||
|
||||
_restoreSort();
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
@ -817,7 +838,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
IconButton(
|
||||
icon: Icon(Icons.favorite, size: 32),
|
||||
onPressed: () async {
|
||||
await deezerAPI.addFavoriteAlbum(playlist.id);
|
||||
await deezerAPI.addPlaylist(playlist.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added to library'.i18n,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
|
@ -833,7 +854,17 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
),
|
||||
PopupMenuButton(
|
||||
child: Icon(Icons.sort, size: 32.0),
|
||||
onSelected: (SortType s) => setState(() => _sort = s),
|
||||
onSelected: (SortType s) async {
|
||||
if (playlist.tracks.length < playlist.trackCount) {
|
||||
//Preload whole playlist
|
||||
playlist = await deezerAPI.fullPlaylist(playlist.id);
|
||||
}
|
||||
setState(() => _sort = s);
|
||||
|
||||
//Save sort type to cache
|
||||
cache.playlistSort[playlist.id] = s;
|
||||
cache.save();
|
||||
},
|
||||
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
||||
PopupMenuItem(
|
||||
value: SortType.DEFAULT,
|
||||
|
|
|
@ -1,68 +1,208 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
|
||||
import 'cached_image.dart';
|
||||
import '../api/download.dart';
|
||||
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
@override
|
||||
_DownloadsScreenState createState() => _DownloadsScreenState();
|
||||
}
|
||||
|
||||
class DownloadTile extends StatelessWidget {
|
||||
class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
|
||||
final Download download;
|
||||
Function onDelete;
|
||||
DownloadTile(this.download, {this.onDelete});
|
||||
List<Download> downloads = [];
|
||||
StreamSubscription _stateSubscription;
|
||||
|
||||
String get subtitle {
|
||||
switch (download.state) {
|
||||
case DownloadState.NONE: return '';
|
||||
case DownloadState.DOWNLOADING:
|
||||
return '${filesize(download.received)} / ${filesize(download.total)}';
|
||||
case DownloadState.POST:
|
||||
return 'Post processing...'.i18n;
|
||||
case DownloadState.DONE:
|
||||
return 'Done'.i18n; //Shouldn't be visible
|
||||
case DownloadState.DEEZER_ERROR:
|
||||
return 'Track is not available on Deezer!'.i18n;
|
||||
case DownloadState.ERROR:
|
||||
return 'Failed to download track! Please restart.'.i18n;
|
||||
}
|
||||
return '';
|
||||
//Sublists
|
||||
List<Download> get downloading => downloads.where((d) => d.state == DownloadState.DOWNLOADING || d.state == DownloadState.POST).toList();
|
||||
List<Download> get queued => downloads.where((d) => d.state == DownloadState.NONE).toList();
|
||||
List<Download> get failed => downloads.where((d) => d.state == DownloadState.ERROR || d.state == DownloadState.DEEZER_ERROR).toList();
|
||||
List<Download> get finished => downloads.where((d) => d.state == DownloadState.DONE).toList();
|
||||
|
||||
Future _load() async {
|
||||
//Load downloads
|
||||
List<Download> _d = await downloadManager.getDownloads();
|
||||
setState(() {
|
||||
downloads = _d;
|
||||
});
|
||||
}
|
||||
|
||||
Widget get progressBar {
|
||||
switch (download.state) {
|
||||
case DownloadState.DOWNLOADING:
|
||||
return LinearProgressIndicator(value: download.received / download.total);
|
||||
case DownloadState.POST:
|
||||
return LinearProgressIndicator();
|
||||
default:
|
||||
return Container(height: 0, width: 0,);
|
||||
@override
|
||||
void initState() {
|
||||
_load();
|
||||
|
||||
//Subscribe to state update
|
||||
_stateSubscription = downloadManager.serviceEvents.stream.listen((e) {
|
||||
//State change = update
|
||||
if (e['action'] == 'onStateChange') {
|
||||
setState(() => downloadManager.running = downloadManager.running);
|
||||
}
|
||||
//Progress change
|
||||
if (e['action'] == 'onProgress') {
|
||||
setState(() {
|
||||
for (Map su in e['data']) {
|
||||
downloads.firstWhere((d) => d.id == su['id'], orElse: () => Download()).updateFromJson(su);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Widget get trailing {
|
||||
if (download.private) {
|
||||
return Icon(Icons.offline_pin);
|
||||
}
|
||||
return Icon(Icons.sd_card);
|
||||
@override
|
||||
void dispose() {
|
||||
if (_stateSubscription != null)
|
||||
_stateSubscription.cancel();
|
||||
_stateSubscription = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text(download.track.title),
|
||||
subtitle: Text(subtitle),
|
||||
leading: CachedImage(
|
||||
url: download.track.albumArt.thumb,
|
||||
width: 48.0,
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Downloads'.i18n),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon:
|
||||
Icon(downloadManager.running ? Icons.stop : Icons.play_arrow),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (downloadManager.running)
|
||||
downloadManager.stop();
|
||||
else
|
||||
downloadManager.start();
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
trailing: trailing,
|
||||
onTap: () {
|
||||
//Delete if none
|
||||
if (download.state == DownloadState.NONE) {
|
||||
body: ListView(
|
||||
children: [
|
||||
//Now downloading
|
||||
Container(height: 2.0),
|
||||
Column(children: List.generate(downloading.length, (int i) => DownloadTile(
|
||||
downloading[i],
|
||||
updateCallback: () => _load(),
|
||||
))),
|
||||
Container(height: 8.0),
|
||||
|
||||
//Queued
|
||||
if (queued.length > 0)
|
||||
Text(
|
||||
'Queued'.i18n,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Column(children: List.generate(queued.length, (int i) => DownloadTile(
|
||||
queued[i],
|
||||
updateCallback: () => _load(),
|
||||
))),
|
||||
if (queued.length > 0)
|
||||
ListTile(
|
||||
title: Text('Clear queue'.i18n),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
await downloadManager.removeDownloads(DownloadState.NONE);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
|
||||
//Failed
|
||||
if (failed.length > 0)
|
||||
Text(
|
||||
'Failed'.i18n,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Column(children: List.generate(failed.length, (int i) => DownloadTile(
|
||||
failed[i],
|
||||
updateCallback: () => _load(),
|
||||
))),
|
||||
//Restart failed
|
||||
if (failed.length > 0)
|
||||
ListTile(
|
||||
title: Text('Restart failed downloads'.i18n),
|
||||
leading: Icon(Icons.restore),
|
||||
onTap: () async {
|
||||
await downloadManager.retryDownloads();
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
if (failed.length > 0)
|
||||
ListTile(
|
||||
title: Text('Clear failed'.i18n),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
await downloadManager.removeDownloads(DownloadState.ERROR);
|
||||
await downloadManager.removeDownloads(DownloadState.DEEZER_ERROR);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
|
||||
//Finished
|
||||
if (finished.length > 0)
|
||||
Text(
|
||||
'Done'.i18n,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Column(children: List.generate(finished.length, (int i) => DownloadTile(
|
||||
finished[i],
|
||||
updateCallback: () => _load(),
|
||||
))),
|
||||
if (finished.length > 0)
|
||||
ListTile(
|
||||
title: Text('Clear downloads history'.i18n),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
await downloadManager.removeDownloads(DownloadState.DONE);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadTile extends StatelessWidget {
|
||||
|
||||
final Download download;
|
||||
final Function updateCallback;
|
||||
DownloadTile(this.download, {this.updateCallback});
|
||||
|
||||
String subtitle() {
|
||||
String out = '';
|
||||
//Download type
|
||||
if (download.private) out += 'Offline'.i18n;
|
||||
else out += 'External'.i18n;
|
||||
out += ' | ';
|
||||
//Quality
|
||||
if (download.quality == 9) out += 'FLAC';
|
||||
if (download.quality == 3) out += 'MP3 320kbps';
|
||||
if (download.quality == 1) out += 'MP3 128kbps';
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Future onClick(BuildContext context) async {
|
||||
if (download.state != DownloadState.DOWNLOADING && download.state != DownloadState.POST) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
|
@ -76,9 +216,9 @@ class DownloadTile extends StatelessWidget {
|
|||
),
|
||||
FlatButton(
|
||||
child: Text('Delete'.i18n),
|
||||
onPressed: () {
|
||||
downloadManager.removeDownload(download);
|
||||
if (this.onDelete != null) this.onDelete();
|
||||
onPressed: () async {
|
||||
await downloadManager.removeDownload(download.id);
|
||||
if (updateCallback != null) updateCallback();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
|
@ -87,121 +227,58 @@ class DownloadTile extends StatelessWidget {
|
|||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
progressBar
|
||||
],
|
||||
}
|
||||
|
||||
//Trailing icon with state
|
||||
Widget trailing() {
|
||||
switch (download.state) {
|
||||
case DownloadState.NONE:
|
||||
return Icon(
|
||||
Icons.query_builder,
|
||||
);
|
||||
case DownloadState.DOWNLOADING:
|
||||
return Icon(
|
||||
Icons.download_rounded
|
||||
);
|
||||
case DownloadState.POST:
|
||||
return Icon(
|
||||
Icons.miscellaneous_services
|
||||
);
|
||||
case DownloadState.DONE:
|
||||
return Icon(
|
||||
Icons.done,
|
||||
color: Colors.green,
|
||||
);
|
||||
case DownloadState.DEEZER_ERROR:
|
||||
return Icon(
|
||||
Icons.error,
|
||||
color: Colors.blue
|
||||
);
|
||||
case DownloadState.ERROR:
|
||||
return Icon(
|
||||
Icons.error,
|
||||
color: Colors.red
|
||||
);
|
||||
}
|
||||
}
|
||||
return Container();
|
||||
}
|
||||
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
@override
|
||||
_DownloadsScreenState createState() => _DownloadsScreenState();
|
||||
}
|
||||
|
||||
class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Downloads'.i18n),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(downloadManager.stopped ? Icons.play_arrow : Icons.stop),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (downloadManager.stopped) downloadManager.start();
|
||||
else downloadManager.stop();
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
StreamBuilder(
|
||||
stream: Stream.periodic(Duration(milliseconds: 500)).asBroadcastStream(), //Periodic to get current download progress
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
|
||||
if (downloadManager.queue.length == 0)
|
||||
return Container(width: 0, height: 0,);
|
||||
|
||||
return Column(
|
||||
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'.i18n),
|
||||
subtitle: Text("This won't delete currently downloading item".i18n),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Delete'.i18n),
|
||||
content: Text('Are you sure you want to delete all queued downloads?'.i18n),
|
||||
actions: [
|
||||
FlatButton(
|
||||
child: Text('Cancel'.i18n),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
title: Text(download.title),
|
||||
leading: CachedImage(url: download.image),
|
||||
subtitle: Text(subtitle()),
|
||||
trailing: trailing(),
|
||||
onTap: () => onClick(context),
|
||||
),
|
||||
FlatButton(
|
||||
child: Text('Delete'.i18n),
|
||||
onPressed: () async {
|
||||
await downloadManager.clearQueue();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
if (download.state == DownloadState.DOWNLOADING)
|
||||
LinearProgressIndicator(value: download.progress),
|
||||
if (download.state == DownloadState.POST)
|
||||
LinearProgressIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
]
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder(
|
||||
future: downloadManager.getFinishedDownloads(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data.length == 0) return Container(height: 0, width: 0,);
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Divider(),
|
||||
Text(
|
||||
'History',
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
...List.generate(snapshot.data.length, (i) {
|
||||
Download d = snapshot.data[i];
|
||||
return DownloadTile(d);
|
||||
}),
|
||||
ListTile(
|
||||
title: Text('Clear downloads history'.i18n),
|
||||
leading: Icon(Icons.delete),
|
||||
subtitle: Text('WARNING: This will only clear non-offline (external downloads)'.i18n),
|
||||
onTap: () async {
|
||||
await downloadManager.cleanDownloadHistory();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:connectivity/connectivity.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
|
@ -57,7 +58,7 @@ class LibraryScreen extends StatelessWidget {
|
|||
body: ListView(
|
||||
children: <Widget>[
|
||||
Container(height: 4.0,),
|
||||
if (downloadManager.stopped && downloadManager.queue.length > 0)
|
||||
if (!downloadManager.running && downloadManager.queueSize > 0)
|
||||
ListTile(
|
||||
title: Text('Downloads'.i18n),
|
||||
leading: Icon(Icons.file_download),
|
||||
|
@ -70,7 +71,7 @@ class LibraryScreen extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
//Dirty if to not use columns
|
||||
if (downloadManager.stopped && downloadManager.queue.length > 0)
|
||||
if (!downloadManager.running && downloadManager.queueSize > 0)
|
||||
Divider(),
|
||||
|
||||
ListTile(
|
||||
|
@ -109,6 +110,15 @@ class LibraryScreen extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('History'.i18n),
|
||||
leading: Icon(Icons.history),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => HistoryScreen())
|
||||
);
|
||||
},
|
||||
),
|
||||
Divider(),
|
||||
ListTile(
|
||||
title: Text('Import'.i18n),
|
||||
|
@ -196,14 +206,49 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
ScrollController _scrollController = ScrollController();
|
||||
List<Track> tracks = [];
|
||||
List<Track> allTracks = [];
|
||||
int trackCount;
|
||||
|
||||
Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId);
|
||||
|
||||
Future _load() async {
|
||||
//Already loaded
|
||||
if (trackCount != null && tracks.length >= trackCount) {
|
||||
//Update tracks cache if fully loaded
|
||||
if (cache.libraryTracks == null || cache.libraryTracks.length != trackCount) {
|
||||
setState(() {
|
||||
cache.libraryTracks = tracks.map((t) => t.id).toList();
|
||||
});
|
||||
await cache.save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectivityResult connectivity = await Connectivity().checkConnectivity();
|
||||
if (connectivity != ConnectivityResult.none) {
|
||||
setState(() => _loading = true);
|
||||
int pos = tracks.length;
|
||||
|
||||
if (trackCount == null || tracks.length == 0) {
|
||||
//Load tracks as a playlist
|
||||
Playlist favPlaylist;
|
||||
try {
|
||||
favPlaylist = await deezerAPI.playlist(deezerAPI.favoritesPlaylistId);
|
||||
} catch (e) {}
|
||||
//Error loading
|
||||
if (favPlaylist == null) {
|
||||
setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
//Update
|
||||
setState(() {
|
||||
trackCount = favPlaylist.trackCount;
|
||||
tracks = favPlaylist.tracks;
|
||||
_makeFavorite();
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//Load another page of tracks from deezer
|
||||
List<Track> _t;
|
||||
try {
|
||||
|
@ -216,6 +261,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
}
|
||||
setState(() {
|
||||
tracks.addAll(_t);
|
||||
_makeFavorite();
|
||||
_loading = false;
|
||||
});
|
||||
|
||||
|
@ -236,6 +282,12 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
});
|
||||
}
|
||||
|
||||
//Update tracks with favorite true
|
||||
void _makeFavorite() {
|
||||
for (int i=0; i<tracks.length; i++)
|
||||
tracks[i].favorite = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_scrollController.addListener(() {
|
||||
|
@ -257,6 +309,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Tracks'.i18n),),
|
||||
body: ListView(
|
||||
controller: _scrollController,
|
||||
children: <Widget>[
|
||||
Card(
|
||||
child: Column(
|
||||
|
@ -554,7 +607,7 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
ListTile(
|
||||
title: Text('Create new playlist'.i18n),
|
||||
leading: Icon(Icons.playlist_add),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
if (settings.offlineMode) {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Cannot create playlists in offline mode'.i18n,
|
||||
|
@ -563,7 +616,8 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
return;
|
||||
}
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.createPlaylist();
|
||||
await m.createPlaylist();
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
Divider(),
|
||||
|
@ -586,6 +640,7 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
favoritesPlaylist.library = true;
|
||||
m.defaultPlaylistMenu(favoritesPlaylist);
|
||||
},
|
||||
),
|
||||
|
@ -600,9 +655,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
)),
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p, onRemove: () {
|
||||
setState(() => _playlists.remove(p));
|
||||
});
|
||||
m.defaultPlaylistMenu(
|
||||
p,
|
||||
onRemove: () {setState(() => _playlists.remove(p));},
|
||||
onUpdate: () {_load();});
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
@ -653,3 +709,49 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryScreen extends StatefulWidget {
|
||||
@override
|
||||
_HistoryScreenState createState() => _HistoryScreenState();
|
||||
}
|
||||
|
||||
class _HistoryScreenState extends State<HistoryScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('History'.i18n),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_sweep),
|
||||
onPressed: () {
|
||||
setState(() => cache.history = []);
|
||||
cache.save();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: (cache.history??[]).length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
Track t = cache.history[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(cache.history, t.id, QueueSource(
|
||||
id: null,
|
||||
text: 'History'.i18n,
|
||||
source: 'history'
|
||||
));
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ class LoginWidget extends StatefulWidget {
|
|||
class _LoginWidgetState extends State<LoginWidget> {
|
||||
|
||||
String _arl;
|
||||
String _error;
|
||||
|
||||
//Initialize deezer etc
|
||||
Future _init() async {
|
||||
|
@ -62,7 +63,14 @@ class _LoginWidgetState extends State<LoginWidget> {
|
|||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Error'.i18n),
|
||||
content: Text('Error logging in! Please check your token and internet connection and try again.'.i18n),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Error logging in! Please check your token and internet connection and try again.'.i18n),
|
||||
if (_error != null)
|
||||
Text('\n\n$_error')
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('Dismiss'.i18n),
|
||||
|
@ -82,13 +90,15 @@ class _LoginWidgetState extends State<LoginWidget> {
|
|||
//Try logging in
|
||||
try {
|
||||
deezerAPI.arl = settings.arl;
|
||||
bool resp = await deezerAPI.authorize();
|
||||
bool resp = await deezerAPI.rawAuthorize(onError: (e) => _error = e.toString());
|
||||
if (resp == false) { //false, not null
|
||||
setState(() => settings.arl = null);
|
||||
errorDialog();
|
||||
}
|
||||
//On error show dialog and reset to null
|
||||
} catch (e) {
|
||||
_error = e;
|
||||
print('Login error: ' + e);
|
||||
setState(() => settings.arl = null);
|
||||
errorDialog();
|
||||
}
|
||||
|
|
227
lib/ui/menu.dart
227
lib/ui/menu.dart
|
@ -1,7 +1,10 @@
|
|||
import 'dart:ffi';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
|
@ -123,7 +126,7 @@ class MenuSheet {
|
|||
showWithTrack(track, [
|
||||
addToQueueNext(track),
|
||||
addToQueue(track),
|
||||
(track.favorite??false)?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track),
|
||||
(cache.checkTrackFavorite(track))?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track),
|
||||
addToPlaylist(track),
|
||||
downloadTrack(track),
|
||||
showAlbum(track.album),
|
||||
|
@ -169,6 +172,11 @@ class MenuSheet {
|
|||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT
|
||||
);
|
||||
//Add to cache
|
||||
if (cache.libraryTracks == null)
|
||||
cache.libraryTracks = [];
|
||||
cache.libraryTracks.add(t.id);
|
||||
|
||||
_close();
|
||||
}
|
||||
);
|
||||
|
@ -179,6 +187,7 @@ class MenuSheet {
|
|||
onTap: () async {
|
||||
await downloadManager.addOfflineTrack(t, private: false);
|
||||
_close();
|
||||
showDownloadStartedToast();
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -186,64 +195,11 @@ class MenuSheet {
|
|||
title: Text('Add to playlist'.i18n),
|
||||
leading: Icon(Icons.playlist_add),
|
||||
onTap: () async {
|
||||
|
||||
Playlist p;
|
||||
|
||||
//Show dialog to pick playlist
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Select playlist'.i18n),
|
||||
content: FutureBuilder(
|
||||
future: deezerAPI.getPlaylists(),
|
||||
builder: (context, snapshot) {
|
||||
|
||||
if (snapshot.hasError) SizedBox(
|
||||
height: 100,
|
||||
child: ErrorScreen(),
|
||||
);
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox(
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator(),),
|
||||
);
|
||||
|
||||
List<Playlist> playlists = snapshot.data;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...List.generate(playlists.length, (i) => ListTile(
|
||||
title: Text(playlists[i].title),
|
||||
leading: CachedImage(
|
||||
url: playlists[i].image.thumb,
|
||||
),
|
||||
onTap: () {
|
||||
p = playlists[i];
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)),
|
||||
ListTile(
|
||||
title: Text('Create new playlist'.i18n),
|
||||
leading: Icon(Icons.add),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CreatePlaylistDialog(tracks: [t],)
|
||||
);
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
//Add to playlist, show toast
|
||||
if (p != null) {
|
||||
return SelectPlaylistDialog(track: t, callback: (Playlist p) async {
|
||||
await deezerAPI.addToPlaylist(t.id, p.id);
|
||||
//Update the playlist if offline
|
||||
if (await downloadManager.checkOffline(playlist: p)) {
|
||||
|
@ -254,8 +210,9 @@ class MenuSheet {
|
|||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
@ -284,11 +241,15 @@ class MenuSheet {
|
|||
if (await downloadManager.checkOffline(playlist: p)) {
|
||||
await downloadManager.addOfflinePlaylist(p);
|
||||
}
|
||||
//Remove from cache
|
||||
if (cache.libraryTracks != null)
|
||||
cache.libraryTracks.removeWhere((i) => i == t.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Track removed from library'.i18n,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
if (onUpdate != null)
|
||||
onUpdate();
|
||||
_close();
|
||||
},
|
||||
|
@ -348,8 +309,9 @@ class MenuSheet {
|
|||
title: Text('Download'.i18n),
|
||||
leading: Icon(Icons.file_download),
|
||||
onTap: () async {
|
||||
await downloadManager.addOfflineAlbum(a, private: false);
|
||||
_close();
|
||||
await downloadManager.addOfflineAlbum(a, private: false);
|
||||
showDownloadStartedToast();
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -360,6 +322,7 @@ class MenuSheet {
|
|||
await deezerAPI.addFavoriteAlbum(a.id);
|
||||
await downloadManager.addOfflineAlbum(a, private: true);
|
||||
_close();
|
||||
showDownloadStartedToast();
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -441,11 +404,13 @@ class MenuSheet {
|
|||
// PLAYLIST
|
||||
//===================
|
||||
|
||||
void defaultPlaylistMenu(Playlist playlist, {List<Widget> options = const [], Function onRemove}) {
|
||||
void defaultPlaylistMenu(Playlist playlist, {List<Widget> options = const [], Function onRemove, Function onUpdate}) {
|
||||
show([
|
||||
playlist.library?removePlaylistLibrary(playlist, onRemove: onRemove):addPlaylistLibrary(playlist),
|
||||
addPlaylistOffline(playlist),
|
||||
downloadPlaylist(playlist),
|
||||
if (playlist.user.id == deezerAPI.userId)
|
||||
editPlaylist(playlist, onUpdate: onUpdate),
|
||||
...options
|
||||
]);
|
||||
}
|
||||
|
@ -492,6 +457,7 @@ class MenuSheet {
|
|||
await deezerAPI.addPlaylist(p.id);
|
||||
downloadManager.addOfflinePlaylist(p, private: true);
|
||||
_close();
|
||||
showDownloadStartedToast();
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -501,6 +467,21 @@ class MenuSheet {
|
|||
onTap: () async {
|
||||
downloadManager.addOfflinePlaylist(p, private: false);
|
||||
_close();
|
||||
showDownloadStartedToast();
|
||||
},
|
||||
);
|
||||
|
||||
Widget editPlaylist(Playlist p, {Function onUpdate}) => ListTile(
|
||||
title: Text('Edit playlist'.i18n),
|
||||
leading: Icon(Icons.edit),
|
||||
onTap: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => CreatePlaylistDialog(playlist: p)
|
||||
);
|
||||
_close();
|
||||
if (onUpdate != null)
|
||||
onUpdate();
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -509,9 +490,17 @@ class MenuSheet {
|
|||
// OTHER
|
||||
//===================
|
||||
|
||||
showDownloadStartedToast() {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Downloads added!'.i18n,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT
|
||||
);
|
||||
}
|
||||
|
||||
//Create playlist
|
||||
void createPlaylist() {
|
||||
showDialog(
|
||||
Future createPlaylist() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return CreatePlaylistDialog();
|
||||
|
@ -523,11 +512,90 @@ class MenuSheet {
|
|||
void _close() => Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
class SelectPlaylistDialog extends StatefulWidget {
|
||||
|
||||
final Track track;
|
||||
final Function callback;
|
||||
SelectPlaylistDialog({this.track, this.callback, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_SelectPlaylistDialogState createState() => _SelectPlaylistDialogState();
|
||||
}
|
||||
|
||||
class _SelectPlaylistDialogState extends State<SelectPlaylistDialog> {
|
||||
|
||||
bool createNew = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
//Create new playlist
|
||||
if (createNew) {
|
||||
if (widget.track == null) {
|
||||
return CreatePlaylistDialog();
|
||||
}
|
||||
return CreatePlaylistDialog(tracks: [widget.track]);
|
||||
}
|
||||
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Select playlist'.i18n),
|
||||
content: FutureBuilder(
|
||||
future: deezerAPI.getPlaylists(),
|
||||
builder: (context, snapshot) {
|
||||
|
||||
if (snapshot.hasError) SizedBox(
|
||||
height: 100,
|
||||
child: ErrorScreen(),
|
||||
);
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox(
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator(),),
|
||||
);
|
||||
|
||||
List<Playlist> playlists = snapshot.data;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...List.generate(playlists.length, (i) => ListTile(
|
||||
title: Text(playlists[i].title),
|
||||
leading: CachedImage(
|
||||
url: playlists[i].image.thumb,
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.callback != null) {
|
||||
widget.callback(playlists[i]);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)),
|
||||
ListTile(
|
||||
title: Text('Create new playlist'.i18n),
|
||||
leading: Icon(Icons.add),
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
createNew = true;
|
||||
});
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class CreatePlaylistDialog extends StatefulWidget {
|
||||
|
||||
final List<Track> tracks;
|
||||
CreatePlaylistDialog({this.tracks, Key key}): super(key: key);
|
||||
//If playlist not null, update
|
||||
final Playlist playlist;
|
||||
CreatePlaylistDialog({this.tracks, this.playlist, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_CreatePlaylistDialogState createState() => _CreatePlaylistDialogState();
|
||||
|
@ -538,11 +606,28 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
|||
int _playlistType = 1;
|
||||
String _title = '';
|
||||
String _description = '';
|
||||
TextEditingController _titleController;
|
||||
TextEditingController _descController;
|
||||
|
||||
//Create or edit mode
|
||||
bool get edit => widget.playlist != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
||||
//Edit playlist mode
|
||||
if (edit) {
|
||||
_titleController = TextEditingController(text: widget.playlist.title);
|
||||
_descController = TextEditingController(text: widget.playlist.description);
|
||||
}
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Create playlist'.i18n),
|
||||
title: Text(edit ? 'Edit playlist'.i18n : 'Create playlist'.i18n),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
|
@ -550,10 +635,12 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
|||
decoration: InputDecoration(
|
||||
labelText: 'Title'.i18n
|
||||
),
|
||||
controller: _titleController ?? TextEditingController(),
|
||||
onChanged: (String s) => _title = s,
|
||||
),
|
||||
TextField(
|
||||
onChanged: (String s) => _description = s,
|
||||
controller: _descController ?? TextEditingController(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description'.i18n
|
||||
),
|
||||
|
@ -583,8 +670,21 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
|||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
FlatButton(
|
||||
child: Text('Create'.i18n),
|
||||
child: Text(edit ? 'Update'.i18n : 'Create'.i18n),
|
||||
onPressed: () async {
|
||||
if (edit) {
|
||||
//Update
|
||||
await deezerAPI.updatePlaylist(
|
||||
widget.playlist.id,
|
||||
_titleController.value.text,
|
||||
_descController.value.text,
|
||||
status: _playlistType
|
||||
);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Playlist updated!'.i18n,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
} else {
|
||||
List<String> tracks = [];
|
||||
if (widget.tracks != null) {
|
||||
tracks = widget.tracks.map<String>((t) => t.id).toList();
|
||||
|
@ -599,6 +699,7 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
|||
msg: 'Playlist created!'.i18n,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
|
|
|
@ -42,6 +42,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
|||
playerHelper.startService();
|
||||
return Center(child: CircularProgressIndicator(),);
|
||||
}
|
||||
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
//Landscape
|
||||
|
@ -388,9 +389,19 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
|||
_l = await deezerAPI.lyrics(_trackId);
|
||||
setState(() => _loading = false);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
//Error Lyrics
|
||||
setState(() => _l = Lyrics().error);
|
||||
setState(() => _l = Lyrics.error());
|
||||
}
|
||||
|
||||
//Empty lyrics
|
||||
if (_l.lyrics.length == 0) {
|
||||
setState(() {
|
||||
_l = Lyrics.error();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
//Use provided lyrics
|
||||
_l = widget.lyrics;
|
||||
|
|
|
@ -11,6 +11,32 @@ import '../api/deezer.dart';
|
|||
import '../api/definitions.dart';
|
||||
import 'error.dart';
|
||||
|
||||
|
||||
openScreenByURL(BuildContext context, String url) async {
|
||||
DeezerLinkResponse res = await deezerAPI.parseLink(url);
|
||||
if (res == null) return;
|
||||
|
||||
switch (res.type) {
|
||||
case DeezerLinkType.TRACK:
|
||||
Track t = await deezerAPI.track(res.id);
|
||||
MenuSheet(context).defaultTrackMenu(t);
|
||||
break;
|
||||
case DeezerLinkType.ALBUM:
|
||||
Album a = await deezerAPI.album(res.id);
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (context) => AlbumDetails(a)));
|
||||
break;
|
||||
case DeezerLinkType.ARTIST:
|
||||
Artist a = await deezerAPI.artist(res.id);
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (context) => ArtistDetails(a)));
|
||||
break;
|
||||
case DeezerLinkType.PLAYLIST:
|
||||
Playlist p = await deezerAPI.playlist(res.id);
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (context) => PlaylistDetails(p)));
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
@override
|
||||
_SearchScreenState createState() => _SearchScreenState();
|
||||
|
@ -20,11 +46,23 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
|
||||
String _query;
|
||||
bool _offline = false;
|
||||
bool _loading = false;
|
||||
TextEditingController _controller = new TextEditingController();
|
||||
List _suggestions = [];
|
||||
|
||||
void _submit(BuildContext context, {String query}) {
|
||||
void _submit(BuildContext context, {String query}) async {
|
||||
if (query != null) _query = query;
|
||||
|
||||
//URL
|
||||
if (_query.startsWith('http')) {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await openScreenByURL(context, _query);
|
||||
} catch (e) {}
|
||||
setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => SearchResultsScreen(_query, offline: _offline,))
|
||||
);
|
||||
|
@ -45,7 +83,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
|
||||
//Load search suggestions
|
||||
Future<List<String>> _loadSuggestions() async {
|
||||
if (_query == null || _query.length < 2) return null;
|
||||
if (_query == null || _query.length < 2 || _query.startsWith('http')) return null;
|
||||
String q = _query;
|
||||
await Future.delayed(Duration(milliseconds: 300));
|
||||
if (q != _query) return null;
|
||||
|
@ -75,7 +113,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
_loadSuggestions();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search'.i18n
|
||||
labelText: 'Search or paste URL'.i18n
|
||||
),
|
||||
controller: _controller,
|
||||
onSubmitted: (String s) => _submit(context, query: s),
|
||||
|
@ -112,6 +150,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
},
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
LinearProgressIndicator(),
|
||||
Divider(),
|
||||
...List.generate((_suggestions??[]).length, (i) => ListTile(
|
||||
title: Text(_suggestions[i]),
|
||||
|
|
|
@ -6,9 +6,12 @@ import 'package:flutter/cupertino.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_material_color_picker/flutter_material_color_picker.dart';
|
||||
import 'package:fluttericon/font_awesome5_icons.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/ui/home_screen.dart';
|
||||
import 'package:i18n_extension/i18n_widget.dart';
|
||||
import 'package:language_pickers/language_pickers.dart';
|
||||
import 'package:language_pickers/languages.dart';
|
||||
|
@ -17,6 +20,7 @@ import 'package:path_provider_ex/path_provider_ex.dart';
|
|||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../settings.dart';
|
||||
import '../main.dart';
|
||||
|
@ -30,20 +34,8 @@ class SettingsScreen extends StatefulWidget {
|
|||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
String _about = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
//Load about text
|
||||
PackageInfo.fromPlatform().then((PackageInfo info) {
|
||||
setState(() {
|
||||
_about = '${info.appName}';
|
||||
});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
List<Map<String, String>> _languages() {
|
||||
//Missing language
|
||||
defaultLanguagesList.add({
|
||||
'name': 'Filipino',
|
||||
'isoCode': 'fil'
|
||||
|
@ -71,6 +63,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
builder: (context) => GeneralSettings()
|
||||
)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Download Settings'.i18n),
|
||||
leading: Icon(Icons.cloud_download),
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => DownloadsSettings()
|
||||
)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Appearance'.i18n),
|
||||
leading: Icon(Icons.color_lens),
|
||||
|
@ -132,11 +131,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
);
|
||||
},
|
||||
),
|
||||
Divider(),
|
||||
Text(
|
||||
_about,
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
ListTile(
|
||||
title: Text('About'.i18n),
|
||||
leading: Icon(Icons.info),
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => CreditsScreen()
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -149,6 +150,10 @@ class AppearanceSettings extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AppearanceSettingsState extends State<AppearanceSettings> {
|
||||
|
||||
|
||||
ColorSwatch<dynamic> _swatch(int c) => ColorSwatch(c, {500: Color(c)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
@ -224,8 +229,19 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
|
|||
return AlertDialog(
|
||||
title: Text('Primary color'.i18n),
|
||||
content: Container(
|
||||
height: 200,
|
||||
height: 240,
|
||||
child: MaterialColorPicker(
|
||||
colors: [
|
||||
...Colors.primaries,
|
||||
//Logo colors
|
||||
_swatch(0xffeca704),
|
||||
_swatch(0xffbe3266),
|
||||
_swatch(0xff4b2e7e),
|
||||
_swatch(0xff384697),
|
||||
_swatch(0xff0880b5),
|
||||
_swatch(0xff009a85),
|
||||
_swatch(0xff2ba766)
|
||||
],
|
||||
allowShades: false,
|
||||
selectedColor: settings.primaryColor,
|
||||
onMainColorChange: (ColorSwatch color) {
|
||||
|
@ -246,10 +262,13 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
|
|||
ListTile(
|
||||
title: Text('Use album art primary color'.i18n),
|
||||
subtitle: Text('Warning: might be buggy'.i18n),
|
||||
leading: Switch(
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.useArtColor,
|
||||
onChanged: (v) => setState(() => settings.updateUseArtColor(v)),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -450,72 +469,86 @@ class _DeezerSettingsState extends State<DeezerSettings> {
|
|||
ListTile(
|
||||
title: Text('Log tracks'.i18n),
|
||||
subtitle: Text('Send track listen logs to Deezer, enable it for features like Flow to work properly'.i18n),
|
||||
leading: Checkbox(
|
||||
leading: Container(
|
||||
width: 30,
|
||||
child: Checkbox(
|
||||
value: settings.logListen,
|
||||
onChanged: (bool v) {
|
||||
setState(() => settings.logListen = v);
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GeneralSettings extends StatefulWidget {
|
||||
@override
|
||||
_GeneralSettingsState createState() => _GeneralSettingsState();
|
||||
}
|
||||
|
||||
class _GeneralSettingsState extends State<GeneralSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('General'.i18n),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Offline mode'.i18n),
|
||||
subtitle: Text('Will be overwritten on start.'.i18n),
|
||||
leading: Switch(
|
||||
value: settings.offlineMode,
|
||||
onChanged: (bool v) {
|
||||
if (v) {
|
||||
setState(() => settings.offlineMode = true);
|
||||
return;
|
||||
}
|
||||
title: Text('Proxy'.i18n),
|
||||
leading: Icon(Icons.vpn_key),
|
||||
subtitle: Text(settings.proxyAddress??'Not set'),
|
||||
onTap: () {
|
||||
String _new;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
deezerAPI.authorize().then((v) {
|
||||
if (v) {
|
||||
setState(() => settings.offlineMode = false);
|
||||
} else {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Error logging in, check your internet connections.'.i18n,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Logging in...'.i18n),
|
||||
content: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator()
|
||||
],
|
||||
title: Text('Proxy'.i18n),
|
||||
content: TextField(
|
||||
onChanged: (String v) => _new = v,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'IP:PORT'
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FlatButton(
|
||||
child: Text('Cancel'.i18n),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
FlatButton(
|
||||
child: Text('Reset'.i18n),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
settings.proxyAddress = null;
|
||||
});
|
||||
await settings.save();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
FlatButton(
|
||||
child: Text('Save'.i18n),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
settings.proxyAddress = _new;
|
||||
});
|
||||
await settings.save();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadsSettings extends StatefulWidget {
|
||||
@override
|
||||
_DownloadsSettingsState createState() => _DownloadsSettingsState();
|
||||
}
|
||||
|
||||
class _DownloadsSettingsState extends State<DownloadsSettings> {
|
||||
|
||||
double _downloadThreads = settings.downloadThreads.toDouble();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Download Settings'.i18n),),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text('Download path'.i18n),
|
||||
leading: Icon(Icons.folder),
|
||||
|
@ -557,7 +590,7 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
|||
),
|
||||
Container(height: 8.0),
|
||||
Text(
|
||||
'Valid variables are'.i18n + ': %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%, %feats%',
|
||||
'Valid variables are'.i18n + ': %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%, %feats%, %playlistTrackNumber%, %0playlistTrackNumber%, %year%',
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
|
@ -598,9 +631,36 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
|||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
'Download threads'.i18n + ': ${_downloadThreads.round().toString()}',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0
|
||||
),
|
||||
),
|
||||
),
|
||||
Slider(
|
||||
min: 1,
|
||||
max: 6,
|
||||
divisions: 5,
|
||||
value: _downloadThreads,
|
||||
label: _downloadThreads.round().toString(),
|
||||
onChanged: (double v) => setState(() => _downloadThreads = v),
|
||||
onChangeEnd: (double val) async {
|
||||
_downloadThreads = val;
|
||||
setState(() {
|
||||
settings.downloadThreads = _downloadThreads.round();
|
||||
_downloadThreads = settings.downloadThreads.toDouble();
|
||||
});
|
||||
await settings.save();
|
||||
}
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Create folders for artist'.i18n),
|
||||
leading: Switch(
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.artistFolder,
|
||||
onChanged: (v) {
|
||||
setState(() => settings.artistFolder = v);
|
||||
|
@ -608,9 +668,12 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
|||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Create folders for albums'.i18n),
|
||||
leading: Switch(
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.albumFolder,
|
||||
onChanged: (v) {
|
||||
setState(() => settings.albumFolder = v);
|
||||
|
@ -618,9 +681,12 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
|||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Separate albums by discs'.i18n),
|
||||
leading: Switch(
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.albumDiscFolder,
|
||||
onChanged: (v) {
|
||||
setState(() => settings.albumDiscFolder = v);
|
||||
|
@ -628,9 +694,12 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
|||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Overwrite already downloaded files'.i18n),
|
||||
leading: Switch(
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.overwriteDownload,
|
||||
onChanged: (v) {
|
||||
setState(() => settings.overwriteDownload = v);
|
||||
|
@ -638,6 +707,95 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
|||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Create folder for playlist'.i18n),
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.playlistFolder,
|
||||
onChanged: (v) {
|
||||
setState(() => settings.playlistFolder = v);
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Download .LRC lyrics'.i18n),
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.downloadLyrics,
|
||||
onChanged: (v) {
|
||||
setState(() => settings.downloadLyrics = v);
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class GeneralSettings extends StatefulWidget {
|
||||
@override
|
||||
_GeneralSettingsState createState() => _GeneralSettingsState();
|
||||
}
|
||||
|
||||
class _GeneralSettingsState extends State<GeneralSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('General'.i18n),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Offline mode'.i18n),
|
||||
subtitle: Text('Will be overwritten on start.'.i18n),
|
||||
leading: Container(
|
||||
width: 30.0,
|
||||
child: Checkbox(
|
||||
value: settings.offlineMode,
|
||||
onChanged: (bool v) {
|
||||
if (v) {
|
||||
setState(() => settings.offlineMode = true);
|
||||
return;
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
deezerAPI.authorize().then((v) {
|
||||
if (v) {
|
||||
setState(() => settings.offlineMode = false);
|
||||
} else {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Error logging in, check your internet connections.'.i18n,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
return AlertDialog(
|
||||
title: Text('Logging in...'.i18n),
|
||||
content: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator()
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Copy ARL'.i18n),
|
||||
subtitle: Text('Copy userToken/ARL Cookie for use in other apps.'.i18n),
|
||||
|
@ -836,3 +994,110 @@ class _DirectoryPickerState extends State<DirectoryPicker> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CreditsScreen extends StatefulWidget {
|
||||
@override
|
||||
_CreditsScreenState createState() => _CreditsScreenState();
|
||||
}
|
||||
|
||||
class _CreditsScreenState extends State<CreditsScreen> {
|
||||
|
||||
String _version = '';
|
||||
|
||||
//Title, Subtitle, URL
|
||||
static final List<List<String>> credits = [
|
||||
['exttex', 'Developer'],
|
||||
['Bas Curtiz', 'Icon, logo, banner, design suggestions, tester'],
|
||||
['Deemix', 'Better app <3', 'https://codeberg.org/RemixDev/deemix'],
|
||||
['Tobs, Homam Al-Rawi, Francesco', 'Beta testers'],
|
||||
['Annexhack', 'Android Auto help']
|
||||
];
|
||||
|
||||
static final List<List<String>> translators = [
|
||||
['Homam Al-Rawi', 'Arabic'],
|
||||
['Markus', 'German'],
|
||||
['Andrea', 'Italian'],
|
||||
['Diego Hiro', 'Portuguese'],
|
||||
['Annexhack', 'Russian'],
|
||||
['Chino Pacia', 'Filipino'],
|
||||
['ArcherDelta & PetFix', 'Spanish'],
|
||||
['Shazzaam', 'Croatian'],
|
||||
['VIRGIN_KLM', 'Greek'],
|
||||
['koreezzz', 'Korean'],
|
||||
['Fwwwwwwwwwweze', 'French'],
|
||||
['kobyrevah', 'Hebrew']
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
PackageInfo.fromPlatform().then((info) {
|
||||
setState(() {
|
||||
_version = 'v${info.version}';
|
||||
});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('About'.i18n),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
FreezerTitle(),
|
||||
Text(
|
||||
_version,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
ListTile(
|
||||
title: Text('Telegram Channel'.i18n),
|
||||
subtitle: Text('To get latest releases'.i18n),
|
||||
leading: Icon(FontAwesome5.telegram, color: Color(0xFF27A2DF), size: 36.0),
|
||||
onTap: () {
|
||||
launch('https://t.me/freezereleases');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Telegram Group'.i18n),
|
||||
subtitle: Text('Official chat'.i18n),
|
||||
leading: Icon(FontAwesome5.telegram, color: Colors.cyan, size: 36.0),
|
||||
onTap: () {
|
||||
launch('https://t.me/freezerandroid');
|
||||
},
|
||||
),
|
||||
Divider(),
|
||||
...List.generate(credits.length, (i) => ListTile(
|
||||
title: Text(credits[i][0]),
|
||||
subtitle: Text(credits[i][1]),
|
||||
onTap: () {
|
||||
if (credits[i].length >= 3) {
|
||||
launch(credits[i][2]);
|
||||
}
|
||||
},
|
||||
)),
|
||||
Divider(),
|
||||
...List.generate(translators.length, (i) => ListTile(
|
||||
title: Text(translators[i][0]),
|
||||
subtitle: Text(translators[i][1]),
|
||||
)),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 4, 0, 8),
|
||||
child: Text(
|
||||
'Huge thanks to all the contributors! <3'.i18n,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
148
pubspec.lock
148
pubspec.lock
|
@ -7,14 +7,14 @@ packages:
|
|||
name: _fe_analyzer_shared
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
version: "11.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.39.14"
|
||||
version: "0.40.4"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -28,7 +28,7 @@ packages:
|
|||
name: async
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.5.0-nullsafety.1"
|
||||
audio_service:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -49,14 +49,14 @@ packages:
|
|||
name: boolean_selector
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0-nullsafety.1"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.5.0"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -77,21 +77,21 @@ packages:
|
|||
name: build_resolvers
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.11"
|
||||
version: "1.4.1"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
version: "1.10.3"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.1"
|
||||
version: "6.0.3"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -119,14 +119,14 @@ packages:
|
|||
name: characters
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.1.0-nullsafety.3"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
version: "1.2.0-nullsafety.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -140,7 +140,7 @@ packages:
|
|||
name: cli_util
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
version: "0.2.0"
|
||||
clipboard:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -154,7 +154,7 @@ packages:
|
|||
name: clock
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.1.0-nullsafety.1"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -168,14 +168,14 @@ packages:
|
|||
name: collection
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.14.13"
|
||||
version: "1.15.0-nullsafety.3"
|
||||
connectivity:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.9+2"
|
||||
version: "0.4.9+3"
|
||||
connectivity_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -189,7 +189,7 @@ packages:
|
|||
name: connectivity_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0+4"
|
||||
version: "0.1.0+5"
|
||||
connectivity_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -245,7 +245,7 @@ packages:
|
|||
name: dart_style
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.6"
|
||||
version: "1.3.7"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -287,7 +287,14 @@ packages:
|
|||
name: fake_async
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "1.2.0-nullsafety.1"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -348,7 +355,7 @@ packages:
|
|||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.4+4"
|
||||
version: "1.4.4+5"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -385,6 +392,13 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fluttericon:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fluttericon
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
fluttertoast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -447,7 +461,7 @@ packages:
|
|||
name: i18n_extension
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.4"
|
||||
version: "1.4.5"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -475,14 +489,14 @@ packages:
|
|||
name: json_annotation
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.1.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
version: "3.5.0"
|
||||
just_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -517,14 +531,14 @@ packages:
|
|||
name: matcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.12.8"
|
||||
version: "0.12.10-nullsafety.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.8"
|
||||
version: "1.3.0-nullsafety.3"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -587,14 +601,14 @@ packages:
|
|||
name: path
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
version: "1.8.0-nullsafety.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.6.14"
|
||||
version: "1.6.18"
|
||||
path_provider_ex:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -615,7 +629,7 @@ packages:
|
|||
name: path_provider_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4+3"
|
||||
version: "0.0.4+4"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -623,13 +637,20 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4+1"
|
||||
pedantic:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pedantic
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
version: "1.9.2"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -657,7 +678,7 @@ packages:
|
|||
name: plugin_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.0.3"
|
||||
pointycastle:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -739,14 +760,14 @@ packages:
|
|||
name: source_gen
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.9.6"
|
||||
version: "0.9.7+1"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
version: "1.8.0-nullsafety.2"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -774,14 +795,14 @@ packages:
|
|||
name: stack_trace
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.5"
|
||||
version: "1.10.0-nullsafety.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0-nullsafety.1"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -795,7 +816,7 @@ packages:
|
|||
name: string_scanner
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
version: "1.1.0-nullsafety.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -809,14 +830,14 @@ packages:
|
|||
name: term_glyph
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "1.2.0-nullsafety.1"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.17"
|
||||
version: "0.2.19-nullsafety.2"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -830,7 +851,49 @@ packages:
|
|||
name: typed_data
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.3.0-nullsafety.3"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.7.2"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+8"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.4+1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+1"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -844,7 +907,7 @@ packages:
|
|||
name: vector_math
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
version: "2.1.0-nullsafety.3"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -859,13 +922,20 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.7.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
version: "0.1.2"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -874,5 +944,5 @@ packages:
|
|||
source: hosted
|
||||
version: "2.2.1"
|
||||
sdks:
|
||||
dart: ">=2.9.0 <3.0.0"
|
||||
dart: ">=2.10.0-110 <2.11.0"
|
||||
flutter: ">=1.20.0 <2.0.0"
|
||||
|
|
|
@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 0.4.2+1
|
||||
version: 0.5.0+1
|
||||
|
||||
environment:
|
||||
sdk: ">=2.8.0 <3.0.0"
|
||||
|
@ -61,9 +61,11 @@ dependencies:
|
|||
flutter_screenutil: ^2.3.0
|
||||
marquee: ^1.5.2
|
||||
flutter_cache_manager: ^1.4.1
|
||||
cached_network_image: ^2.2.0+1
|
||||
cached_network_image: ^2.3.2+1
|
||||
clipboard: ^0.1.2+8
|
||||
i18n_extension: ^1.4.4
|
||||
fluttericon: ^1.0.7
|
||||
url_launcher: ^5.7.2
|
||||
|
||||
audio_session: ^0.0.7
|
||||
audio_service:
|
||||
|
|
Loading…
Reference in New Issue