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 pub get
|
||||||
flutter build apk
|
flutter build apk
|
||||||
```
|
```
|
||||||
|
NOTE: You have to use own keys, or build debug using `flutter build apk --debug`
|
||||||
|
|
||||||
## Telegram group
|
## Telegram group
|
||||||
https://t.me/freezerandroid
|
https://t.me/freezerandroid
|
||||||
|
@ -46,6 +47,11 @@ Diego Hiro: Portuguese
|
||||||
Annexhack: Russian
|
Annexhack: Russian
|
||||||
Chino Pacia: Filipino
|
Chino Pacia: Filipino
|
||||||
ArcherDelta & PetFix: Spanish
|
ArcherDelta & PetFix: Spanish
|
||||||
|
Shazzaam: Croatian
|
||||||
|
VIRGIN_KLM: Greek
|
||||||
|
koreezzz: Korean
|
||||||
|
Fwwwwwwwwwweze: French
|
||||||
|
kobyrevah: Hebrew
|
||||||
|
|
||||||
### just_audio, audio_service
|
### just_audio, audio_service
|
||||||
This app depends on modified just_audio and audio_service plugins with Deezer support.
|
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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="f.f.freezer">
|
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.
|
calls FlutterMain.startInitialization(this); in its onCreate method.
|
||||||
In most cases you can leave this as-is, but you if you want to provide
|
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
|
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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="io.flutter.app.FlutterApplication"
|
android:name="io.flutter.app.FlutterApplication"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="Freezer"
|
android:label="Freezer"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".DownloadService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
</service>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:theme="@style/LaunchTheme"
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
android:windowSoftInputMode="adjustResize">
|
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
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
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
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
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
|
Drawable until Flutter paints its first frame, then this splash
|
||||||
screen fades out. A splash screen is useful to avoid any visual
|
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
|
gap between the end of Android's launch screen and the painting of
|
||||||
Flutter's first frame. -->
|
Flutter's first frame.
|
||||||
|
-->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.SplashScreenDrawable"
|
android:name="io.flutter.embedding.android.SplashScreenDrawable"
|
||||||
android:resource="@drawable/launch_background"
|
android:resource="@drawable/launch_background" />
|
||||||
/>
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
|
||||||
<service android:name="com.ryanheise.audioservice.AudioService">
|
<service android:name="com.ryanheise.audioservice.AudioService">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" >
|
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<meta-data android:name="com.google.android.gms.car.application"
|
<meta-data
|
||||||
android:resource="@xml/automotive_app_desc"/>
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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;
|
package f.f.freezer;
|
||||||
|
|
||||||
|
import android.content.ComponentName;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.ServiceConnection;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.sqlite.SQLiteDatabase;
|
||||||
import android.net.Uri;
|
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 android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
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.BufferedInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.RandomAccessFile;
|
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.function.Function;
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
import javax.crypto.Cipher;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity;
|
import io.flutter.embedding.android.FlutterActivity;
|
||||||
import io.flutter.embedding.engine.FlutterEngine;
|
import io.flutter.embedding.engine.FlutterEngine;
|
||||||
|
import io.flutter.plugin.common.EventChannel;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
import io.flutter.plugins.GeneratedPluginRegistrant;
|
||||||
|
|
||||||
|
import static f.f.freezer.Deezer.bytesToHex;
|
||||||
|
|
||||||
public class MainActivity extends FlutterActivity {
|
public class MainActivity extends FlutterActivity {
|
||||||
private static final String CHANNEL = "f.f.freezer/native";
|
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
|
@Override
|
||||||
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
|
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
|
||||||
super.configureFlutterEngine(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 'dart:async';
|
||||||
|
|
||||||
|
import 'package:dio/adapter.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:cookie_jar/cookie_jar.dart';
|
import 'package:cookie_jar/cookie_jar.dart';
|
||||||
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||||
|
import 'package:freezer/api/cache.dart';
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
@ -47,6 +49,15 @@ class DeezerAPI {
|
||||||
return options;
|
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
|
//Add cookies
|
||||||
List<Cookie> cookies = [Cookie('arl', this.arl)];
|
List<Cookie> cookies = [Cookie('arl', this.arl)];
|
||||||
_cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies);
|
_cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies);
|
||||||
|
@ -82,13 +93,13 @@ class DeezerAPI {
|
||||||
//Wrapper so it can be globally awaited
|
//Wrapper so it can be globally awaited
|
||||||
Future authorize() async {
|
Future authorize() async {
|
||||||
if (_authorizing == null) {
|
if (_authorizing == null) {
|
||||||
this._authorizing = this._authorize();
|
this._authorizing = this.rawAuthorize();
|
||||||
}
|
}
|
||||||
return _authorizing;
|
return _authorizing;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Authorize, bool = success
|
//Authorize, bool = success
|
||||||
Future<bool> _authorize() async {
|
Future<bool> rawAuthorize({Function onError}) async {
|
||||||
try {
|
try {
|
||||||
Map<dynamic, dynamic> data = await callApi('deezer.getUserData');
|
Map<dynamic, dynamic> data = await callApi('deezer.getUserData');
|
||||||
if (data['results']['USER']['USER_ID'] == 0) {
|
if (data['results']['USER']['USER_ID'] == 0) {
|
||||||
|
@ -100,7 +111,31 @@ class DeezerAPI {
|
||||||
this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
|
this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
|
||||||
return true;
|
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
|
//Search
|
||||||
|
@ -168,19 +203,6 @@ class DeezerAPI {
|
||||||
//Get playlist with all tracks
|
//Get playlist with all tracks
|
||||||
Future<Playlist> fullPlaylist(String id) async {
|
Future<Playlist> fullPlaylist(String id) async {
|
||||||
return await playlist(id, nb: 100000);
|
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
|
//Add track to favorites
|
||||||
|
@ -271,7 +293,7 @@ class DeezerAPI {
|
||||||
Map data = await callApi('song.getLyrics', params: {
|
Map data = await callApi('song.getLyrics', params: {
|
||||||
'sng_id': trackId
|
'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']);
|
return Lyrics.fromPrivateJson(data['results']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -318,7 +340,15 @@ class DeezerAPI {
|
||||||
|
|
||||||
//Log song listen to deezer
|
//Log song listen to deezer
|
||||||
Future logListen(String trackId) async {
|
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 {
|
Future<HomePage> getChannel(String target) async {
|
||||||
|
@ -406,5 +436,16 @@ class DeezerAPI {
|
||||||
});
|
});
|
||||||
return data['results']['data'].map<Track>((t) => Track.fromPrivateJson(t)).toList();
|
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:pointycastle/block/modes/ecb.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:freezer/translations.i18n.dart';
|
||||||
import 'package:crypto/crypto.dart' as crypto;
|
import 'package:crypto/crypto.dart' as crypto;
|
||||||
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
@ -30,8 +31,6 @@ class Track {
|
||||||
bool offline;
|
bool offline;
|
||||||
Lyrics lyrics;
|
Lyrics lyrics;
|
||||||
bool favorite;
|
bool favorite;
|
||||||
|
|
||||||
//TODO: Not in DB
|
|
||||||
int diskNumber;
|
int diskNumber;
|
||||||
bool explicit;
|
bool explicit;
|
||||||
|
|
||||||
|
@ -102,6 +101,10 @@ class Track {
|
||||||
artists = jsonDecode(mi.extras['artists']).map<Artist>((j) => Artist.fromJson(j)).toList();
|
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(
|
return Track(
|
||||||
title: mi.title??mi.displayTitle,
|
title: mi.title??mi.displayTitle,
|
||||||
artists: artists,
|
artists: artists,
|
||||||
|
@ -112,7 +115,7 @@ class Track {
|
||||||
thumbUrl: mi.extras['thumb']
|
thumbUrl: mi.extras['thumb']
|
||||||
),
|
),
|
||||||
duration: mi.duration,
|
duration: mi.duration,
|
||||||
playbackDetails: null, // So it gets updated from api
|
playbackDetails: playbackDetails,
|
||||||
lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}"))
|
lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -149,7 +152,9 @@ class Track {
|
||||||
'trackNumber': trackNumber,
|
'trackNumber': trackNumber,
|
||||||
'offline': off?1:0,
|
'offline': off?1:0,
|
||||||
'lyrics': jsonEncode(lyrics.toJson()),
|
'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(
|
factory Track.fromSQL(Map<String, dynamic> data) => Track(
|
||||||
id: data['trackId']??data['id'], //If loading from downloads table
|
id: data['trackId']??data['id'], //If loading from downloads table
|
||||||
|
@ -163,7 +168,9 @@ class Track {
|
||||||
)),
|
)),
|
||||||
offline: (data['offline'] == 1) ? true:false,
|
offline: (data['offline'] == 1) ? true:false,
|
||||||
lyrics: Lyrics.fromJson(jsonDecode(data['lyrics'])),
|
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);
|
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||||
|
@ -186,8 +193,6 @@ class Album {
|
||||||
int fans;
|
int fans;
|
||||||
bool offline; //If the album is offline, or just saved in db as metadata
|
bool offline; //If the album is offline, or just saved in db as metadata
|
||||||
bool library;
|
bool library;
|
||||||
|
|
||||||
//TODO: Not in DB
|
|
||||||
AlbumType type;
|
AlbumType type;
|
||||||
String releaseDate;
|
String releaseDate;
|
||||||
|
|
||||||
|
@ -224,7 +229,9 @@ class Album {
|
||||||
'art': art.full,
|
'art': art.full,
|
||||||
'fans': fans,
|
'fans': fans,
|
||||||
'offline': off?1:0,
|
'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(
|
factory Album.fromSQL(Map<String, dynamic> data) => Album(
|
||||||
id: data['id'],
|
id: data['id'],
|
||||||
|
@ -238,7 +245,9 @@ class Album {
|
||||||
art: ImageDetails(fullUrl: data['art']),
|
art: ImageDetails(fullUrl: data['art']),
|
||||||
fans: data['fans'],
|
fans: data['fans'],
|
||||||
offline: (data['offline'] == 1) ? true:false,
|
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);
|
factory Album.fromJson(Map<String, dynamic> json) => _$AlbumFromJson(json);
|
||||||
|
@ -256,8 +265,6 @@ class Artist {
|
||||||
int fans;
|
int fans;
|
||||||
bool offline;
|
bool offline;
|
||||||
bool library;
|
bool library;
|
||||||
|
|
||||||
//TODO: NOT IN DB
|
|
||||||
bool radio;
|
bool radio;
|
||||||
|
|
||||||
Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library, this.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,
|
'fans': fans,
|
||||||
'albumCount': this.albumCount??(this.albums??[]).length,
|
'albumCount': this.albumCount??(this.albums??[]).length,
|
||||||
'offline': off?1:0,
|
'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(
|
factory Artist.fromSQL(Map<String, dynamic> data) => Artist(
|
||||||
id: data['id'],
|
id: data['id'],
|
||||||
|
@ -311,7 +319,8 @@ class Artist {
|
||||||
picture: ImageDetails(fullUrl: data['picture']),
|
picture: ImageDetails(fullUrl: data['picture']),
|
||||||
fans: data['fans'],
|
fans: data['fans'],
|
||||||
offline: (data['offline'] == 1)?true:false,
|
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);
|
factory Artist.fromJson(Map<String, dynamic> json) => _$ArtistFromJson(json);
|
||||||
|
@ -456,12 +465,12 @@ class Lyrics {
|
||||||
|
|
||||||
Lyrics({this.id, this.writers, this.lyrics});
|
Lyrics({this.id, this.writers, this.lyrics});
|
||||||
|
|
||||||
Lyrics get error => Lyrics(
|
static error() => Lyrics(
|
||||||
id: id,
|
id: null,
|
||||||
writers: writers,
|
writers: null,
|
||||||
lyrics: [Lyric(
|
lyrics: [Lyric(
|
||||||
offset: Duration(milliseconds: 0),
|
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(
|
Lyrics l = Lyrics(
|
||||||
id: json['LYRICS_ID'],
|
id: json['LYRICS_ID'],
|
||||||
writers: json['LYRICS_WRITERS'],
|
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
|
//Clean empty lyrics
|
||||||
l.lyrics.removeWhere((l) => l.offset == null);
|
l.lyrics.removeWhere((l) => l.offset == null);
|
||||||
|
@ -724,3 +733,27 @@ enum RepeatType {
|
||||||
LIST,
|
LIST,
|
||||||
TRACK
|
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_service/audio_service.dart';
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:freezer/api/cache.dart';
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/ui/android_auto.dart';
|
import 'package:freezer/ui/android_auto.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
@ -21,6 +24,7 @@ PlayerHelper playerHelper = PlayerHelper();
|
||||||
class PlayerHelper {
|
class PlayerHelper {
|
||||||
|
|
||||||
StreamSubscription _customEventSubscription;
|
StreamSubscription _customEventSubscription;
|
||||||
|
StreamSubscription _mediaItemSubscription;
|
||||||
StreamSubscription _playbackStateStreamSubscription;
|
StreamSubscription _playbackStateStreamSubscription;
|
||||||
QueueSource queueSource;
|
QueueSource queueSource;
|
||||||
LoopMode repeatType = LoopMode.off;
|
LoopMode repeatType = LoopMode.off;
|
||||||
|
@ -65,9 +69,26 @@ class PlayerHelper {
|
||||||
//Log song (if allowed)
|
//Log song (if allowed)
|
||||||
if (event == null) return;
|
if (event == null) return;
|
||||||
if (event.processingState == AudioProcessingState.ready && event.playing) {
|
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
|
//Start audio_service
|
||||||
startService();
|
startService();
|
||||||
}
|
}
|
||||||
|
@ -79,7 +100,7 @@ class PlayerHelper {
|
||||||
androidEnableQueue: true,
|
androidEnableQueue: true,
|
||||||
androidStopForegroundOnPause: false,
|
androidStopForegroundOnPause: false,
|
||||||
androidNotificationOngoing: false,
|
androidNotificationOngoing: false,
|
||||||
androidNotificationClickStartsActivity: true,
|
androidNotificationClickStartsActivity: false,
|
||||||
androidNotificationChannelDescription: 'Freezer',
|
androidNotificationChannelDescription: 'Freezer',
|
||||||
androidNotificationChannelName: 'Freezer',
|
androidNotificationChannelName: 'Freezer',
|
||||||
androidNotificationIcon: 'drawable/ic_logo',
|
androidNotificationIcon: 'drawable/ic_logo',
|
||||||
|
@ -110,6 +131,7 @@ class PlayerHelper {
|
||||||
Future onExit() async {
|
Future onExit() async {
|
||||||
_customEventSubscription.cancel();
|
_customEventSubscription.cancel();
|
||||||
_playbackStateStreamSubscription.cancel();
|
_playbackStateStreamSubscription.cancel();
|
||||||
|
_mediaItemSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
//Replace queue, play specified track id
|
//Replace queue, play specified track id
|
||||||
|
@ -256,6 +278,13 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
});
|
});
|
||||||
//Update state on all clients on change
|
//Update state on all clients on change
|
||||||
_eventSub = _player.playbackEventStream.listen((event) {
|
_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();
|
_broadcastState();
|
||||||
});
|
});
|
||||||
_player.processingStateStream.listen((state) {
|
_player.processingStateStream.listen((state) {
|
||||||
|
@ -296,6 +325,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
|
|
||||||
//Skip in player
|
//Skip in player
|
||||||
await _player.seek(Duration.zero, index: newIndex);
|
await _player.seek(Duration.zero, index: newIndex);
|
||||||
|
_queueIndex = newIndex;
|
||||||
_skipState = null;
|
_skipState = null;
|
||||||
onPlay();
|
onPlay();
|
||||||
}
|
}
|
||||||
|
@ -327,6 +357,40 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
@override
|
@override
|
||||||
Future<void> onSeekBackward(bool begin) async => _seekContinuously(begin, -1);
|
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
|
@override
|
||||||
Future<List<MediaItem>> onLoadChildren(String parentMediaId) async {
|
Future<List<MediaItem>> onLoadChildren(String parentMediaId) async {
|
||||||
AudioServiceBackground.sendCustomEvent({
|
AudioServiceBackground.sendCustomEvent({
|
||||||
|
@ -417,12 +481,16 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
this._queue = q;
|
this._queue = q;
|
||||||
AudioServiceBackground.setQueue(_queue);
|
AudioServiceBackground.setQueue(_queue);
|
||||||
//Load
|
//Load
|
||||||
|
_queueIndex = 0;
|
||||||
await _loadQueue();
|
await _loadQueue();
|
||||||
await _player.seek(Duration.zero, index: 0);
|
//await _player.seek(Duration.zero, index: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
//Load queue to just_audio
|
//Load queue to just_audio
|
||||||
Future _loadQueue() async {
|
Future _loadQueue() async {
|
||||||
|
//Don't reset queue index by starting player
|
||||||
|
int qi = _queueIndex;
|
||||||
|
|
||||||
List<AudioSource> sources = [];
|
List<AudioSource> sources = [];
|
||||||
for(int i=0; i<_queue.length; i++) {
|
for(int i=0; i<_queue.length; i++) {
|
||||||
sources.add(await _mediaItemToAudioSource(_queue[i]));
|
sources.add(await _mediaItemToAudioSource(_queue[i]));
|
||||||
|
@ -432,9 +500,11 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
//Load in just_audio
|
//Load in just_audio
|
||||||
try {
|
try {
|
||||||
await _player.load(_audioSource);
|
await _player.load(_audioSource);
|
||||||
|
await _player.seek(Duration.zero, index: qi);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
//Error loading tracks
|
//Error loading tracks
|
||||||
}
|
}
|
||||||
|
_queueIndex = qi;
|
||||||
AudioServiceBackground.setMediaItem(mediaItem);
|
AudioServiceBackground.setMediaItem(mediaItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -523,13 +593,14 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
|
|
||||||
//Export queue to JSON
|
//Export queue to JSON
|
||||||
Future _saveQueue() async {
|
Future _saveQueue() async {
|
||||||
|
if (_queueIndex == 0 && _queue.length == 0) return;
|
||||||
|
|
||||||
String path = await _getQueuePath();
|
String path = await _getQueuePath();
|
||||||
File f = File(path);
|
File f = File(path);
|
||||||
//Create if doesnt exist
|
//Create if doesn't exist
|
||||||
if (! await File(path).exists()) {
|
if (! await File(path).exists()) {
|
||||||
f = await f.create();
|
f = await f.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map data = {
|
Map data = {
|
||||||
'index': _queueIndex,
|
'index': _queueIndex,
|
||||||
'queue': _queue.map<Map<String, dynamic>>((mi) => mi.toJson()).toList(),
|
'queue': _queue.map<Map<String, dynamic>>((mi) => mi.toJson()).toList(),
|
||||||
|
@ -552,7 +623,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
if (_queue != null) {
|
if (_queue != null) {
|
||||||
await AudioServiceBackground.setQueue(_queue);
|
await AudioServiceBackground.setQueue(_queue);
|
||||||
await _loadQueue();
|
await _loadQueue();
|
||||||
AudioServiceBackground.setMediaItem(mediaItem);
|
await AudioServiceBackground.setMediaItem(mediaItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//Send restored queue source to ui
|
//Send restored queue source to ui
|
||||||
|
@ -568,7 +639,6 @@ class AudioPlayerTask extends BackgroundAudioTask {
|
||||||
//-1 == play next
|
//-1 == play next
|
||||||
if (index == -1) index = _queueIndex + 1;
|
if (index == -1) index = _queueIndex + 1;
|
||||||
|
|
||||||
|
|
||||||
_queue.insert(index, mi);
|
_queue.insert(index, mi);
|
||||||
await AudioServiceBackground.setQueue(_queue);
|
await AudioServiceBackground.setQueue(_queue);
|
||||||
await _audioSource.insert(index, await _mediaItemToAudioSource(mi));
|
await _audioSource.insert(index, await _mediaItemToAudioSource(mi));
|
||||||
|
|
|
@ -49,6 +49,7 @@ class SpotifyAPI {
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false}) async {
|
Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false}) async {
|
||||||
doneImporting = false;
|
doneImporting = false;
|
||||||
importingSpotifyPlaylist = playlist;
|
importingSpotifyPlaylist = playlist;
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
const language_gr_el = {
|
/*
|
||||||
"gr_el": {
|
|
||||||
|
Translated by: VIRGIN_KLM
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
const language_el_gr = {
|
||||||
|
"el_gr": {
|
||||||
"Home": "Αρχική",
|
"Home": "Αρχική",
|
||||||
"Search": "Αναζήτηση",
|
"Search": "Αναζήτηση",
|
||||||
"Library": "Βιβλιοθήκη",
|
"Library": "Βιβλιοθήκη",
|
||||||
|
|
|
@ -160,7 +160,7 @@ const language_en_us = {
|
||||||
"Clear": "Clear",
|
"Clear": "Clear",
|
||||||
"Create folders for artist": "Create folders for artist",
|
"Create folders for artist": "Create folders for artist",
|
||||||
"Create folders for albums": "Create folders for albums",
|
"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",
|
"Overwrite already downloaded files": "Overwrite already downloaded files",
|
||||||
"Copy ARL": "Copy ARL",
|
"Copy ARL": "Copy ARL",
|
||||||
"Copy userToken/ARL Cookie for use in other apps.":
|
"Copy userToken/ARL Cookie for use in other apps.":
|
||||||
|
@ -182,6 +182,33 @@ const language_en_us = {
|
||||||
"Radio": "Radio",
|
"Radio": "Radio",
|
||||||
"Flow": "Flow",
|
"Flow": "Flow",
|
||||||
"Track is not available on Deezer!": "Track is not available on Deezer!",
|
"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": "Главная",
|
"Home": "Главная",
|
||||||
"Search": "Поиск",
|
"Search": "Поиск",
|
||||||
"Library": "Библиотека",
|
"Library": "Библиотека",
|
||||||
"Offline mode, can't play flow or smart track lists.":
|
"Offline mode, can't play flow or smart track lists.": "Офлайн режим, нельзя воспроизводить потоки или умные списки треков.",
|
||||||
"Автономный режим, нельзя воспроизводить потоки или умные списки треков.",
|
|
||||||
"Added to library": "Добавить в библиотеку",
|
"Added to library": "Добавить в библиотеку",
|
||||||
"Download": "Скачать",
|
"Download": "Скачать",
|
||||||
"Disk": "Disk",
|
"Disk": "Диск",
|
||||||
"Offline": "Офлайн",
|
"Offline": "Скачанные треки",
|
||||||
"Top Tracks": "Лучшие треки",
|
"Top Tracks": "Лучшие треки",
|
||||||
"Show more tracks": "Показать больше треков",
|
"Show more tracks": "Показать больше треков",
|
||||||
"Top": "Top",
|
"Top": "Top",
|
||||||
|
@ -24,17 +23,16 @@ const language_ru_ru = {
|
||||||
"Default": "По умолчанию",
|
"Default": "По умолчанию",
|
||||||
"Reverse": "Обратный",
|
"Reverse": "Обратный",
|
||||||
"Alphabetic": "По алфавиту",
|
"Alphabetic": "По алфавиту",
|
||||||
"Artist": "Артист",
|
"Artist": "Исполнитель",
|
||||||
"Post processing...": "Постобработка...",
|
"Post processing...": "Постобработка...",
|
||||||
"Done": "Готово",
|
"Done": "Готово",
|
||||||
"Delete": "Удалить",
|
"Delete": "Удалить",
|
||||||
"Are you sure you want to delete this download?":
|
"Are you sure you want to delete this download?":
|
||||||
"Вы действительно хотите удалить эту загрузку??",
|
"Вы действительно хотите удалить эту загрузку?",
|
||||||
"Cancel": "Отмена",
|
"Cancel": "Отмена",
|
||||||
"Downloads": "Загрузки",
|
"Downloads": "Загрузки",
|
||||||
"Clear queue": "Очистить очередь",
|
"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?":
|
"Are you sure you want to delete all queued downloads?":
|
||||||
"Вы действительно хотите удалить все загрузки в очереди?",
|
"Вы действительно хотите удалить все загрузки в очереди?",
|
||||||
"Clear downloads history": "Очистить историю загрузок",
|
"Clear downloads history": "Очистить историю загрузок",
|
||||||
|
@ -44,20 +42,18 @@ const language_ru_ru = {
|
||||||
"Пожалуйста, проверьте ваше соединение и повторите попытку позже...",
|
"Пожалуйста, проверьте ваше соединение и повторите попытку позже...",
|
||||||
"Show more": "Показать больше",
|
"Show more": "Показать больше",
|
||||||
"Importer": "Импортер",
|
"Importer": "Импортер",
|
||||||
"Currently supporting only Spotify, with 100 tracks limit":
|
"Currently supporting only Spotify, with 100 tracks limit": "В настоящее время поддерживается только Spotify, с ограничением 100 треков",
|
||||||
"В настоящее время поддерживается только Spotify с ограничением 100 треков",
|
|
||||||
"Due to API limitations": "Из-за ограничений API",
|
"Due to API limitations": "Из-за ограничений API",
|
||||||
"Enter your playlist link below": "Введите ссылку на свой плейлист ниже",
|
"Enter your playlist link below": "Введите ссылку на свой плейлист ниже",
|
||||||
"Error loading URL!": "Ошибка загрузки URL!",
|
"Error loading URL!": "Ошибка загрузки URL!",
|
||||||
"Convert": "Перерабатывать",
|
"Convert": "Перерабатывать",
|
||||||
"Download only": "Только скачиные",
|
"Download only": "Только скачанные",
|
||||||
"Downloading is currently stopped, click here to resume.":
|
"Downloading is currently stopped, click here to resume.": "В настоящее время загрузка остановлена, нажмите здесь, чтобы возобновить.",
|
||||||
"В настоящее время загрузка остановлена, нажмите здесь, чтобы возобновить.",
|
|
||||||
"Tracks": "Треки",
|
"Tracks": "Треки",
|
||||||
"Albums": "Альбомы",
|
"Albums": "Альбомы",
|
||||||
"Artists": "Артисты",
|
"Artists": "Артисты",
|
||||||
"Playlists": "Плейлисты",
|
"Playlists": "Плейлисты",
|
||||||
"Import": "Import",
|
"Import": "Импорт",
|
||||||
"Import playlists from Spotify": "Импортировать плейлисты из Spotify",
|
"Import playlists from Spotify": "Импортировать плейлисты из Spotify",
|
||||||
"Statistics": "Статистика",
|
"Statistics": "Статистика",
|
||||||
"Offline tracks": "Автономные треки",
|
"Offline tracks": "Автономные треки",
|
||||||
|
@ -67,10 +63,9 @@ const language_ru_ru = {
|
||||||
"Free space": "Свободное место",
|
"Free space": "Свободное место",
|
||||||
"Loved tracks": "Любимые треки",
|
"Loved tracks": "Любимые треки",
|
||||||
"Favorites": "Избранное",
|
"Favorites": "Избранное",
|
||||||
"All offline tracks": "Все оффлайн треки",
|
"All offline tracks": "Скачанные треки",
|
||||||
"Create new playlist": "Создать новый плейлист",
|
"Create new playlist": "Создать новый плейлист",
|
||||||
"Cannot create playlists in offline mode":
|
"Cannot create playlists in offline mode": "Невозможно создавать плейлисты в автономном режиме",
|
||||||
"Невозможно создавать плейлисты в автономном режиме",
|
|
||||||
"Error": "Ошибка",
|
"Error": "Ошибка",
|
||||||
"Error logging in! Please check your token and internet connection and try again.":
|
"Error logging in! Please check your token and internet connection and try again.":
|
||||||
"Ошибка входа! Проверьте свой токен и подключение к Интернету и повторите попытку.",
|
"Ошибка входа! Проверьте свой токен и подключение к Интернету и повторите попытку.",
|
||||||
|
@ -127,39 +122,38 @@ const language_ru_ru = {
|
||||||
"Show all playlists": "Показать все плейлисты",
|
"Show all playlists": "Показать все плейлисты",
|
||||||
"Settings": "Настройки",
|
"Settings": "Настройки",
|
||||||
"General": "Общее",
|
"General": "Общее",
|
||||||
"Appearance": "Внешность",
|
"Appearance": "Интерфейс",
|
||||||
"Quality": "Качественный",
|
"Quality": "Качество звука",
|
||||||
"Deezer": "Deezer",
|
"Deezer": "Deezer",
|
||||||
"Theme": "Тема",
|
"Theme": "Тема",
|
||||||
"Currently": "В настоящее время",
|
"Currently": "Выбрана тема",
|
||||||
"Select theme": "Выберите тему",
|
"Select theme": "Выберите тему",
|
||||||
"Light (default)": "Светлая (По умолчанию)",
|
"Light (default)": "Светлая (По умолчанию)",
|
||||||
"Dark": "Темная",
|
"Dark": "Dark (Темная тема)",
|
||||||
"Black (AMOLED)": "Черная (AMOLED)",
|
"Black (AMOLED)": "Черная (AMOLED)",
|
||||||
"Deezer (Dark)": "Deezer (Dark)",
|
"Deezer (Dark)": "Deezer (Dark)",
|
||||||
"Primary color": "Основной цвет",
|
"Primary color": "Основной цвет",
|
||||||
"Selected color": "Выбранный цвет",
|
"Selected color": "Выбранный цвет",
|
||||||
"Use album art primary color": "Использовать основной цвет обложки альбома",
|
"Use album art primary color": "Использовать цвет обложки",
|
||||||
"Warning: might be buggy": "Предупреждение: может быть ошибка",
|
"Warning: might be buggy": "Предупреждение: может быть ошибка",
|
||||||
"Mobile streaming": "Мобильная трансляция",
|
"Mobile streaming": "Мобильная сеть",
|
||||||
"Wifi streaming": "Wifi трансляция",
|
"Wifi streaming": "Wifi сеть",
|
||||||
"External downloads": "Внешние загрузки",
|
"External downloads": "Внешние загрузки",
|
||||||
"Content language": "Язык содержания",
|
"Content language": "Язык содержания",
|
||||||
"Not app language, used in headers. Now":
|
"Not app language, used in headers. Now": "Используемый в заголовках. Сейчас",
|
||||||
"Не язык приложения, используемый в заголовках. Сейчас",
|
|
||||||
"Select language": "Выберите язык",
|
"Select language": "Выберите язык",
|
||||||
"Content country": "Страна содержания",
|
"Content country": "Страна содержания",
|
||||||
"Country used in headers. Now": "Страна, используемая в заголовках. Сейчас",
|
"Country used in headers. Now": "Страна, используемая в заголовках. Сейчас",
|
||||||
"Log tracks": "Журнал треков",
|
"Log tracks": "Журнал треков",
|
||||||
"Send track listen logs to Deezer, enable it for features like Flow to work properly":
|
"Send track listen logs to Deezer, enable it for features like Flow to work properly":
|
||||||
"Отправьте журналы прослушивания треков в Deezer, включите его, чтобы такие функции, как Flow, работали правильно",
|
"Отправьте журналы прослушивания треков в Deezer, включите его, чтобы такие функции, как Flow, работали правильно",
|
||||||
"Offline mode": "Автономный режим",
|
"Offline mode": "Офлайн режим",
|
||||||
"Will be overwritten on start.": "Будет перезаписан при запуске.",
|
"Will be overwritten on start.": "Будет перезаписан при запуске.",
|
||||||
"Error logging in, check your internet connections.":
|
"Error logging in, check your internet connections.":
|
||||||
"Ошибка при входе, проверьте свои интернет-соединения.",
|
"Ошибка при входе, проверьте свои интернет-соединения.",
|
||||||
"Logging in...": "Происходит вход в систему...",
|
"Logging in...": "Происходит вход в систему...",
|
||||||
"Download path": "Скачать путь",
|
"Download path": "Путь сохранения файлов",
|
||||||
"Downloads naming": "Именование загрузок",
|
"Downloads naming": "Название при скачивании",
|
||||||
"Downloaded tracks filename": "Имя файла загруженных треков",
|
"Downloaded tracks filename": "Имя файла загруженных треков",
|
||||||
"Valid variables are": "Допустимые переменные:",
|
"Valid variables are": "Допустимые переменные:",
|
||||||
"Reset": "Сброс",
|
"Reset": "Сброс",
|
||||||
|
@ -181,7 +175,7 @@ const language_ru_ru = {
|
||||||
"Select storage": "Выберите хранилище",
|
"Select storage": "Выберите хранилище",
|
||||||
"Go up": "Подниматься",
|
"Go up": "Подниматься",
|
||||||
"Permission denied": "Доступ запрещен",
|
"Permission denied": "Доступ запрещен",
|
||||||
"Language": "Язык",
|
"Language": "Язык приложения",
|
||||||
"Language changed, please restart Freezer to apply!": "Язык изменен, перезапустите Freezer, чтобы применить!",
|
"Language changed, please restart Freezer to apply!": "Язык изменен, перезапустите Freezer, чтобы применить!",
|
||||||
"Importing...": "Импорт...",
|
"Importing...": "Импорт...",
|
||||||
"Radio": "Радио"
|
"Radio": "Радио"
|
||||||
|
|
|
@ -2,7 +2,9 @@ import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:custom_navigator/custom_navigator.dart';
|
import 'package:custom_navigator/custom_navigator.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:freezer/api/cache.dart';
|
||||||
import 'package:freezer/ui/library.dart';
|
import 'package:freezer/ui/library.dart';
|
||||||
import 'package:freezer/ui/login_screen.dart';
|
import 'package:freezer/ui/login_screen.dart';
|
||||||
import 'package:freezer/ui/search.dart';
|
import 'package:freezer/ui/search.dart';
|
||||||
|
@ -28,8 +30,8 @@ void main() async {
|
||||||
|
|
||||||
//Initialize globals
|
//Initialize globals
|
||||||
settings = await Settings().loadSettings();
|
settings = await Settings().loadSettings();
|
||||||
//await imagesDatabase.init();
|
|
||||||
await downloadManager.init();
|
await downloadManager.init();
|
||||||
|
cache = await Cache.load();
|
||||||
|
|
||||||
runApp(FreezerApp());
|
runApp(FreezerApp());
|
||||||
}
|
}
|
||||||
|
@ -108,7 +110,7 @@ class _LoginMainWrapperState extends State<LoginMainWrapper> {
|
||||||
//Load token on background
|
//Load token on background
|
||||||
deezerAPI.arl = settings.arl;
|
deezerAPI.arl = settings.arl;
|
||||||
settings.offlineMode = true;
|
settings.offlineMode = true;
|
||||||
deezerAPI.authorize().then((b) {
|
deezerAPI.authorize().then((b) async {
|
||||||
if (b) setState(() => settings.offlineMode = false);
|
if (b) setState(() => settings.offlineMode = false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:freezer/api/download.dart';
|
||||||
import 'package:freezer/main.dart';
|
import 'package:freezer/main.dart';
|
||||||
import 'package:freezer/ui/cached_image.dart';
|
import 'package:freezer/ui/cached_image.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
@ -51,6 +52,12 @@ class Settings {
|
||||||
bool albumDiscFolder;
|
bool albumDiscFolder;
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool overwriteDownload;
|
bool overwriteDownload;
|
||||||
|
@JsonKey(defaultValue: 2)
|
||||||
|
int downloadThreads;
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
bool playlistFolder;
|
||||||
|
@JsonKey(defaultValue: true)
|
||||||
|
bool downloadLyrics;
|
||||||
|
|
||||||
|
|
||||||
//Appearance
|
//Appearance
|
||||||
|
@ -76,6 +83,8 @@ class Settings {
|
||||||
String deezerCountry;
|
String deezerCountry;
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool logListen;
|
bool logListen;
|
||||||
|
@JsonKey(defaultValue: null)
|
||||||
|
String proxyAddress;
|
||||||
|
|
||||||
Settings({this.downloadPath, this.arl});
|
Settings({this.downloadPath, this.arl});
|
||||||
|
|
||||||
|
@ -138,6 +147,14 @@ class Settings {
|
||||||
return ThemeData();
|
return ThemeData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//JSON to forward into download service
|
||||||
|
Map getServiceSettings() {
|
||||||
|
return {
|
||||||
|
"downloadThreads": downloadThreads,
|
||||||
|
"overwriteDownload": overwriteDownload,
|
||||||
|
"downloadLyrics": downloadLyrics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void updateUseArtColor(bool v) {
|
void updateUseArtColor(bool v) {
|
||||||
useArtColor = v;
|
useArtColor = v;
|
||||||
|
@ -181,6 +198,7 @@ class Settings {
|
||||||
Future save() async {
|
Future save() async {
|
||||||
File f = File(await getPath());
|
File f = File(await getPath());
|
||||||
await f.writeAsString(jsonEncode(this.toJson()));
|
await f.writeAsString(jsonEncode(this.toJson()));
|
||||||
|
downloadManager.updateServiceSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future updateAudioServiceQuality() async {
|
Future updateAudioServiceQuality() async {
|
||||||
|
|
|
@ -30,13 +30,17 @@ Settings _$SettingsFromJson(Map<String, dynamic> json) {
|
||||||
..artistFolder = json['artistFolder'] as bool ?? true
|
..artistFolder = json['artistFolder'] as bool ?? true
|
||||||
..albumDiscFolder = json['albumDiscFolder'] as bool ?? false
|
..albumDiscFolder = json['albumDiscFolder'] as bool ?? false
|
||||||
..overwriteDownload = json['overwriteDownload'] 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 =
|
..theme =
|
||||||
_$enumDecodeNullable(_$ThemesEnumMap, json['theme']) ?? Themes.Light
|
_$enumDecodeNullable(_$ThemesEnumMap, json['theme']) ?? Themes.Light
|
||||||
..primaryColor = Settings._colorFromJson(json['primaryColor'] as int)
|
..primaryColor = Settings._colorFromJson(json['primaryColor'] as int)
|
||||||
..useArtColor = json['useArtColor'] as bool ?? false
|
..useArtColor = json['useArtColor'] as bool ?? false
|
||||||
..deezerLanguage = json['deezerLanguage'] as String ?? 'en'
|
..deezerLanguage = json['deezerLanguage'] as String ?? 'en'
|
||||||
..deezerCountry = json['deezerCountry'] as String ?? 'US'
|
..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>{
|
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
||||||
|
@ -52,12 +56,16 @@ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
||||||
'artistFolder': instance.artistFolder,
|
'artistFolder': instance.artistFolder,
|
||||||
'albumDiscFolder': instance.albumDiscFolder,
|
'albumDiscFolder': instance.albumDiscFolder,
|
||||||
'overwriteDownload': instance.overwriteDownload,
|
'overwriteDownload': instance.overwriteDownload,
|
||||||
|
'downloadThreads': instance.downloadThreads,
|
||||||
|
'playlistFolder': instance.playlistFolder,
|
||||||
|
'downloadLyrics': instance.downloadLyrics,
|
||||||
'theme': _$ThemesEnumMap[instance.theme],
|
'theme': _$ThemesEnumMap[instance.theme],
|
||||||
'primaryColor': Settings._colorToJson(instance.primaryColor),
|
'primaryColor': Settings._colorToJson(instance.primaryColor),
|
||||||
'useArtColor': instance.useArtColor,
|
'useArtColor': instance.useArtColor,
|
||||||
'deezerLanguage': instance.deezerLanguage,
|
'deezerLanguage': instance.deezerLanguage,
|
||||||
'deezerCountry': instance.deezerCountry,
|
'deezerCountry': instance.deezerCountry,
|
||||||
'logListen': instance.logListen,
|
'logListen': instance.logListen,
|
||||||
|
'proxyAddress': instance.proxyAddress,
|
||||||
};
|
};
|
||||||
|
|
||||||
T _$enumDecode<T>(
|
T _$enumDecode<T>(
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:freezer/languages/ar_ar.dart';
|
import 'package:freezer/languages/ar_ar.dart';
|
||||||
import 'package:freezer/languages/de_de.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/en_us.dart';
|
||||||
import 'package:freezer/languages/es_es.dart';
|
import 'package:freezer/languages/es_es.dart';
|
||||||
import 'package:freezer/languages/fil_ph.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/it_it.dart';
|
||||||
|
import 'package:freezer/languages/ko_ko.dart';
|
||||||
import 'package:freezer/languages/pt_br.dart';
|
import 'package:freezer/languages/pt_br.dart';
|
||||||
import 'package:freezer/languages/ru_ru.dart';
|
import 'package:freezer/languages/ru_ru.dart';
|
||||||
import 'package:i18n_extension/i18n_extension.dart';
|
import 'package:i18n_extension/i18n_extension.dart';
|
||||||
|
@ -17,12 +22,19 @@ const supportedLocales = [
|
||||||
const Locale('de', 'DE'),
|
const Locale('de', 'DE'),
|
||||||
const Locale('ru', 'RU'),
|
const Locale('ru', 'RU'),
|
||||||
const Locale('es', 'ES'),
|
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')
|
const Locale('fil', 'PH')
|
||||||
];
|
];
|
||||||
|
|
||||||
extension Localization on String {
|
extension Localization on String {
|
||||||
static var _t = Translations.byLocale("en_US") +
|
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);
|
String get i18n => localize(this, _t);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:freezer/api/cache.dart';
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/api/download.dart';
|
import 'package:freezer/api/download.dart';
|
||||||
import 'package:freezer/api/player.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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
playlist = widget.playlist;
|
playlist = widget.playlist;
|
||||||
|
@ -717,6 +736,8 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_restoreSort();
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -817,7 +838,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.favorite, size: 32),
|
icon: Icon(Icons.favorite, size: 32),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await deezerAPI.addFavoriteAlbum(playlist.id);
|
await deezerAPI.addPlaylist(playlist.id);
|
||||||
Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: 'Added to library'.i18n,
|
msg: 'Added to library'.i18n,
|
||||||
toastLength: Toast.LENGTH_SHORT,
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
@ -833,7 +854,17 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
||||||
),
|
),
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
child: Icon(Icons.sort, size: 32.0),
|
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>>[
|
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: SortType.DEFAULT,
|
value: SortType.DEFAULT,
|
||||||
|
|
|
@ -1,68 +1,208 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:filesize/filesize.dart';
|
import 'package:filesize/filesize.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:freezer/api/download.dart';
|
||||||
import 'package:freezer/translations.i18n.dart';
|
import 'package:freezer/translations.i18n.dart';
|
||||||
|
|
||||||
import 'cached_image.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;
|
List<Download> downloads = [];
|
||||||
Function onDelete;
|
StreamSubscription _stateSubscription;
|
||||||
DownloadTile(this.download, {this.onDelete});
|
|
||||||
|
|
||||||
String get subtitle {
|
//Sublists
|
||||||
switch (download.state) {
|
List<Download> get downloading => downloads.where((d) => d.state == DownloadState.DOWNLOADING || d.state == DownloadState.POST).toList();
|
||||||
case DownloadState.NONE: return '';
|
List<Download> get queued => downloads.where((d) => d.state == DownloadState.NONE).toList();
|
||||||
case DownloadState.DOWNLOADING:
|
List<Download> get failed => downloads.where((d) => d.state == DownloadState.ERROR || d.state == DownloadState.DEEZER_ERROR).toList();
|
||||||
return '${filesize(download.received)} / ${filesize(download.total)}';
|
List<Download> get finished => downloads.where((d) => d.state == DownloadState.DONE).toList();
|
||||||
case DownloadState.POST:
|
|
||||||
return 'Post processing...'.i18n;
|
Future _load() async {
|
||||||
case DownloadState.DONE:
|
//Load downloads
|
||||||
return 'Done'.i18n; //Shouldn't be visible
|
List<Download> _d = await downloadManager.getDownloads();
|
||||||
case DownloadState.DEEZER_ERROR:
|
setState(() {
|
||||||
return 'Track is not available on Deezer!'.i18n;
|
downloads = _d;
|
||||||
case DownloadState.ERROR:
|
});
|
||||||
return 'Failed to download track! Please restart.'.i18n;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget get progressBar {
|
@override
|
||||||
switch (download.state) {
|
void initState() {
|
||||||
case DownloadState.DOWNLOADING:
|
_load();
|
||||||
return LinearProgressIndicator(value: download.received / download.total);
|
|
||||||
case DownloadState.POST:
|
//Subscribe to state update
|
||||||
return LinearProgressIndicator();
|
_stateSubscription = downloadManager.serviceEvents.stream.listen((e) {
|
||||||
default:
|
//State change = update
|
||||||
return Container(height: 0, width: 0,);
|
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 {
|
@override
|
||||||
if (download.private) {
|
void dispose() {
|
||||||
return Icon(Icons.offline_pin);
|
if (_stateSubscription != null)
|
||||||
}
|
_stateSubscription.cancel();
|
||||||
return Icon(Icons.sd_card);
|
_stateSubscription = null;
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Scaffold(
|
||||||
mainAxisSize: MainAxisSize.min,
|
appBar: AppBar(
|
||||||
children: <Widget>[
|
title: Text('Downloads'.i18n),
|
||||||
ListTile(
|
actions: [
|
||||||
title: Text(download.track.title),
|
IconButton(
|
||||||
subtitle: Text(subtitle),
|
icon:
|
||||||
leading: CachedImage(
|
Icon(downloadManager.running ? Icons.stop : Icons.play_arrow),
|
||||||
url: download.track.albumArt.thumb,
|
onPressed: () {
|
||||||
width: 48.0,
|
setState(() {
|
||||||
|
if (downloadManager.running)
|
||||||
|
downloadManager.stop();
|
||||||
|
else
|
||||||
|
downloadManager.start();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
trailing: trailing,
|
body: ListView(
|
||||||
onTap: () {
|
children: [
|
||||||
//Delete if none
|
//Now downloading
|
||||||
if (download.state == DownloadState.NONE) {
|
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(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
@ -76,9 +216,9 @@ class DownloadTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
FlatButton(
|
FlatButton(
|
||||||
child: Text('Delete'.i18n),
|
child: Text('Delete'.i18n),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
downloadManager.removeDownload(download);
|
await downloadManager.removeDownload(download.id);
|
||||||
if (this.onDelete != null) this.onDelete();
|
if (updateCallback != null) updateCallback();
|
||||||
Navigator.of(context).pop();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Column(
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
title: Text('Clear queue'.i18n),
|
title: Text(download.title),
|
||||||
subtitle: Text("This won't delete currently downloading item".i18n),
|
leading: CachedImage(url: download.image),
|
||||||
leading: Icon(Icons.delete),
|
subtitle: Text(subtitle()),
|
||||||
onTap: () async {
|
trailing: trailing(),
|
||||||
showDialog(
|
onTap: () => onClick(context),
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
FlatButton(
|
if (download.state == DownloadState.DOWNLOADING)
|
||||||
child: Text('Delete'.i18n),
|
LinearProgressIndicator(value: download.progress),
|
||||||
onPressed: () async {
|
if (download.state == DownloadState.POST)
|
||||||
await downloadManager.clearQueue();
|
LinearProgressIndicator(),
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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:connectivity/connectivity.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:freezer/api/cache.dart';
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/api/definitions.dart';
|
import 'package:freezer/api/definitions.dart';
|
||||||
import 'package:freezer/api/player.dart';
|
import 'package:freezer/api/player.dart';
|
||||||
|
@ -57,7 +58,7 @@ class LibraryScreen extends StatelessWidget {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Container(height: 4.0,),
|
Container(height: 4.0,),
|
||||||
if (downloadManager.stopped && downloadManager.queue.length > 0)
|
if (!downloadManager.running && downloadManager.queueSize > 0)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Downloads'.i18n),
|
title: Text('Downloads'.i18n),
|
||||||
leading: Icon(Icons.file_download),
|
leading: Icon(Icons.file_download),
|
||||||
|
@ -70,7 +71,7 @@ class LibraryScreen extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
//Dirty if to not use columns
|
//Dirty if to not use columns
|
||||||
if (downloadManager.stopped && downloadManager.queue.length > 0)
|
if (!downloadManager.running && downloadManager.queueSize > 0)
|
||||||
Divider(),
|
Divider(),
|
||||||
|
|
||||||
ListTile(
|
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(),
|
Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Import'.i18n),
|
title: Text('Import'.i18n),
|
||||||
|
@ -196,14 +206,49 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
||||||
ScrollController _scrollController = ScrollController();
|
ScrollController _scrollController = ScrollController();
|
||||||
List<Track> tracks = [];
|
List<Track> tracks = [];
|
||||||
List<Track> allTracks = [];
|
List<Track> allTracks = [];
|
||||||
|
int trackCount;
|
||||||
|
|
||||||
Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId);
|
Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId);
|
||||||
|
|
||||||
Future _load() async {
|
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();
|
ConnectivityResult connectivity = await Connectivity().checkConnectivity();
|
||||||
if (connectivity != ConnectivityResult.none) {
|
if (connectivity != ConnectivityResult.none) {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
int pos = tracks.length;
|
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
|
//Load another page of tracks from deezer
|
||||||
List<Track> _t;
|
List<Track> _t;
|
||||||
try {
|
try {
|
||||||
|
@ -216,6 +261,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
tracks.addAll(_t);
|
tracks.addAll(_t);
|
||||||
|
_makeFavorite();
|
||||||
_loading = false;
|
_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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
_scrollController.addListener(() {
|
_scrollController.addListener(() {
|
||||||
|
@ -257,6 +309,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text('Tracks'.i18n),),
|
appBar: AppBar(title: Text('Tracks'.i18n),),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
controller: _scrollController,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Card(
|
Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -554,7 +607,7 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Create new playlist'.i18n),
|
title: Text('Create new playlist'.i18n),
|
||||||
leading: Icon(Icons.playlist_add),
|
leading: Icon(Icons.playlist_add),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
if (settings.offlineMode) {
|
if (settings.offlineMode) {
|
||||||
Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: 'Cannot create playlists in offline mode'.i18n,
|
msg: 'Cannot create playlists in offline mode'.i18n,
|
||||||
|
@ -563,7 +616,8 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MenuSheet m = MenuSheet(context);
|
MenuSheet m = MenuSheet(context);
|
||||||
m.createPlaylist();
|
await m.createPlaylist();
|
||||||
|
await _load();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Divider(),
|
Divider(),
|
||||||
|
@ -586,6 +640,7 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
||||||
},
|
},
|
||||||
onHold: () {
|
onHold: () {
|
||||||
MenuSheet m = MenuSheet(context);
|
MenuSheet m = MenuSheet(context);
|
||||||
|
favoritesPlaylist.library = true;
|
||||||
m.defaultPlaylistMenu(favoritesPlaylist);
|
m.defaultPlaylistMenu(favoritesPlaylist);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -600,9 +655,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
||||||
)),
|
)),
|
||||||
onHold: () {
|
onHold: () {
|
||||||
MenuSheet m = MenuSheet(context);
|
MenuSheet m = MenuSheet(context);
|
||||||
m.defaultPlaylistMenu(p, onRemove: () {
|
m.defaultPlaylistMenu(
|
||||||
setState(() => _playlists.remove(p));
|
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> {
|
class _LoginWidgetState extends State<LoginWidget> {
|
||||||
|
|
||||||
String _arl;
|
String _arl;
|
||||||
|
String _error;
|
||||||
|
|
||||||
//Initialize deezer etc
|
//Initialize deezer etc
|
||||||
Future _init() async {
|
Future _init() async {
|
||||||
|
@ -62,7 +63,14 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('Error'.i18n),
|
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>[
|
actions: <Widget>[
|
||||||
FlatButton(
|
FlatButton(
|
||||||
child: Text('Dismiss'.i18n),
|
child: Text('Dismiss'.i18n),
|
||||||
|
@ -82,13 +90,15 @@ class _LoginWidgetState extends State<LoginWidget> {
|
||||||
//Try logging in
|
//Try logging in
|
||||||
try {
|
try {
|
||||||
deezerAPI.arl = settings.arl;
|
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
|
if (resp == false) { //false, not null
|
||||||
setState(() => settings.arl = null);
|
setState(() => settings.arl = null);
|
||||||
errorDialog();
|
errorDialog();
|
||||||
}
|
}
|
||||||
//On error show dialog and reset to null
|
//On error show dialog and reset to null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_error = e;
|
||||||
|
print('Login error: ' + e);
|
||||||
setState(() => settings.arl = null);
|
setState(() => settings.arl = null);
|
||||||
errorDialog();
|
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/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:freezer/api/cache.dart';
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
import 'package:freezer/api/download.dart';
|
import 'package:freezer/api/download.dart';
|
||||||
import 'package:freezer/ui/details_screens.dart';
|
import 'package:freezer/ui/details_screens.dart';
|
||||||
|
@ -123,7 +126,7 @@ class MenuSheet {
|
||||||
showWithTrack(track, [
|
showWithTrack(track, [
|
||||||
addToQueueNext(track),
|
addToQueueNext(track),
|
||||||
addToQueue(track),
|
addToQueue(track),
|
||||||
(track.favorite??false)?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track),
|
(cache.checkTrackFavorite(track))?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track),
|
||||||
addToPlaylist(track),
|
addToPlaylist(track),
|
||||||
downloadTrack(track),
|
downloadTrack(track),
|
||||||
showAlbum(track.album),
|
showAlbum(track.album),
|
||||||
|
@ -169,6 +172,11 @@ class MenuSheet {
|
||||||
gravity: ToastGravity.BOTTOM,
|
gravity: ToastGravity.BOTTOM,
|
||||||
toastLength: Toast.LENGTH_SHORT
|
toastLength: Toast.LENGTH_SHORT
|
||||||
);
|
);
|
||||||
|
//Add to cache
|
||||||
|
if (cache.libraryTracks == null)
|
||||||
|
cache.libraryTracks = [];
|
||||||
|
cache.libraryTracks.add(t.id);
|
||||||
|
|
||||||
_close();
|
_close();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -179,6 +187,7 @@ class MenuSheet {
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await downloadManager.addOfflineTrack(t, private: false);
|
await downloadManager.addOfflineTrack(t, private: false);
|
||||||
_close();
|
_close();
|
||||||
|
showDownloadStartedToast();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -186,64 +195,11 @@ class MenuSheet {
|
||||||
title: Text('Add to playlist'.i18n),
|
title: Text('Add to playlist'.i18n),
|
||||||
leading: Icon(Icons.playlist_add),
|
leading: Icon(Icons.playlist_add),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
|
||||||
Playlist p;
|
|
||||||
|
|
||||||
//Show dialog to pick playlist
|
//Show dialog to pick playlist
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return AlertDialog(
|
return SelectPlaylistDialog(track: t, callback: (Playlist p) async {
|
||||||
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) {
|
|
||||||
await deezerAPI.addToPlaylist(t.id, p.id);
|
await deezerAPI.addToPlaylist(t.id, p.id);
|
||||||
//Update the playlist if offline
|
//Update the playlist if offline
|
||||||
if (await downloadManager.checkOffline(playlist: p)) {
|
if (await downloadManager.checkOffline(playlist: p)) {
|
||||||
|
@ -254,8 +210,9 @@ class MenuSheet {
|
||||||
toastLength: Toast.LENGTH_SHORT,
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
gravity: ToastGravity.BOTTOM,
|
gravity: ToastGravity.BOTTOM,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
_close();
|
_close();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -284,11 +241,15 @@ class MenuSheet {
|
||||||
if (await downloadManager.checkOffline(playlist: p)) {
|
if (await downloadManager.checkOffline(playlist: p)) {
|
||||||
await downloadManager.addOfflinePlaylist(p);
|
await downloadManager.addOfflinePlaylist(p);
|
||||||
}
|
}
|
||||||
|
//Remove from cache
|
||||||
|
if (cache.libraryTracks != null)
|
||||||
|
cache.libraryTracks.removeWhere((i) => i == t.id);
|
||||||
Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: 'Track removed from library'.i18n,
|
msg: 'Track removed from library'.i18n,
|
||||||
toastLength: Toast.LENGTH_SHORT,
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
gravity: ToastGravity.BOTTOM
|
gravity: ToastGravity.BOTTOM
|
||||||
);
|
);
|
||||||
|
if (onUpdate != null)
|
||||||
onUpdate();
|
onUpdate();
|
||||||
_close();
|
_close();
|
||||||
},
|
},
|
||||||
|
@ -348,8 +309,9 @@ class MenuSheet {
|
||||||
title: Text('Download'.i18n),
|
title: Text('Download'.i18n),
|
||||||
leading: Icon(Icons.file_download),
|
leading: Icon(Icons.file_download),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await downloadManager.addOfflineAlbum(a, private: false);
|
|
||||||
_close();
|
_close();
|
||||||
|
await downloadManager.addOfflineAlbum(a, private: false);
|
||||||
|
showDownloadStartedToast();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -360,6 +322,7 @@ class MenuSheet {
|
||||||
await deezerAPI.addFavoriteAlbum(a.id);
|
await deezerAPI.addFavoriteAlbum(a.id);
|
||||||
await downloadManager.addOfflineAlbum(a, private: true);
|
await downloadManager.addOfflineAlbum(a, private: true);
|
||||||
_close();
|
_close();
|
||||||
|
showDownloadStartedToast();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -441,11 +404,13 @@ class MenuSheet {
|
||||||
// PLAYLIST
|
// PLAYLIST
|
||||||
//===================
|
//===================
|
||||||
|
|
||||||
void defaultPlaylistMenu(Playlist playlist, {List<Widget> options = const [], Function onRemove}) {
|
void defaultPlaylistMenu(Playlist playlist, {List<Widget> options = const [], Function onRemove, Function onUpdate}) {
|
||||||
show([
|
show([
|
||||||
playlist.library?removePlaylistLibrary(playlist, onRemove: onRemove):addPlaylistLibrary(playlist),
|
playlist.library?removePlaylistLibrary(playlist, onRemove: onRemove):addPlaylistLibrary(playlist),
|
||||||
addPlaylistOffline(playlist),
|
addPlaylistOffline(playlist),
|
||||||
downloadPlaylist(playlist),
|
downloadPlaylist(playlist),
|
||||||
|
if (playlist.user.id == deezerAPI.userId)
|
||||||
|
editPlaylist(playlist, onUpdate: onUpdate),
|
||||||
...options
|
...options
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -492,6 +457,7 @@ class MenuSheet {
|
||||||
await deezerAPI.addPlaylist(p.id);
|
await deezerAPI.addPlaylist(p.id);
|
||||||
downloadManager.addOfflinePlaylist(p, private: true);
|
downloadManager.addOfflinePlaylist(p, private: true);
|
||||||
_close();
|
_close();
|
||||||
|
showDownloadStartedToast();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -501,6 +467,21 @@ class MenuSheet {
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
downloadManager.addOfflinePlaylist(p, private: false);
|
downloadManager.addOfflinePlaylist(p, private: false);
|
||||||
_close();
|
_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
|
// OTHER
|
||||||
//===================
|
//===================
|
||||||
|
|
||||||
|
showDownloadStartedToast() {
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: 'Downloads added!'.i18n,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
toastLength: Toast.LENGTH_SHORT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
//Create playlist
|
//Create playlist
|
||||||
void createPlaylist() {
|
Future createPlaylist() async {
|
||||||
showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return CreatePlaylistDialog();
|
return CreatePlaylistDialog();
|
||||||
|
@ -523,11 +512,90 @@ class MenuSheet {
|
||||||
void _close() => Navigator.of(context).pop();
|
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 {
|
class CreatePlaylistDialog extends StatefulWidget {
|
||||||
|
|
||||||
final List<Track> tracks;
|
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
|
@override
|
||||||
_CreatePlaylistDialogState createState() => _CreatePlaylistDialogState();
|
_CreatePlaylistDialogState createState() => _CreatePlaylistDialogState();
|
||||||
|
@ -538,11 +606,28 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
||||||
int _playlistType = 1;
|
int _playlistType = 1;
|
||||||
String _title = '';
|
String _title = '';
|
||||||
String _description = '';
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('Create playlist'.i18n),
|
title: Text(edit ? 'Edit playlist'.i18n : 'Create playlist'.i18n),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
@ -550,10 +635,12 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Title'.i18n
|
labelText: 'Title'.i18n
|
||||||
),
|
),
|
||||||
|
controller: _titleController ?? TextEditingController(),
|
||||||
onChanged: (String s) => _title = s,
|
onChanged: (String s) => _title = s,
|
||||||
),
|
),
|
||||||
TextField(
|
TextField(
|
||||||
onChanged: (String s) => _description = s,
|
onChanged: (String s) => _description = s,
|
||||||
|
controller: _descController ?? TextEditingController(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Description'.i18n
|
labelText: 'Description'.i18n
|
||||||
),
|
),
|
||||||
|
@ -583,8 +670,21 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
FlatButton(
|
FlatButton(
|
||||||
child: Text('Create'.i18n),
|
child: Text(edit ? 'Update'.i18n : 'Create'.i18n),
|
||||||
onPressed: () async {
|
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 = [];
|
List<String> tracks = [];
|
||||||
if (widget.tracks != null) {
|
if (widget.tracks != null) {
|
||||||
tracks = widget.tracks.map<String>((t) => t.id).toList();
|
tracks = widget.tracks.map<String>((t) => t.id).toList();
|
||||||
|
@ -599,6 +699,7 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
||||||
msg: 'Playlist created!'.i18n,
|
msg: 'Playlist created!'.i18n,
|
||||||
gravity: ToastGravity.BOTTOM
|
gravity: ToastGravity.BOTTOM
|
||||||
);
|
);
|
||||||
|
}
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -42,6 +42,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||||
playerHelper.startService();
|
playerHelper.startService();
|
||||||
return Center(child: CircularProgressIndicator(),);
|
return Center(child: CircularProgressIndicator(),);
|
||||||
}
|
}
|
||||||
|
|
||||||
return OrientationBuilder(
|
return OrientationBuilder(
|
||||||
builder: (context, orientation) {
|
builder: (context, orientation) {
|
||||||
//Landscape
|
//Landscape
|
||||||
|
@ -388,9 +389,19 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
||||||
_l = await deezerAPI.lyrics(_trackId);
|
_l = await deezerAPI.lyrics(_trackId);
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print(e);
|
||||||
//Error Lyrics
|
//Error Lyrics
|
||||||
setState(() => _l = Lyrics().error);
|
setState(() => _l = Lyrics.error());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Empty lyrics
|
||||||
|
if (_l.lyrics.length == 0) {
|
||||||
|
setState(() {
|
||||||
|
_l = Lyrics.error();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
//Use provided lyrics
|
//Use provided lyrics
|
||||||
_l = widget.lyrics;
|
_l = widget.lyrics;
|
||||||
|
|
|
@ -11,6 +11,32 @@ import '../api/deezer.dart';
|
||||||
import '../api/definitions.dart';
|
import '../api/definitions.dart';
|
||||||
import 'error.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 {
|
class SearchScreen extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
_SearchScreenState createState() => _SearchScreenState();
|
_SearchScreenState createState() => _SearchScreenState();
|
||||||
|
@ -20,11 +46,23 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
|
|
||||||
String _query;
|
String _query;
|
||||||
bool _offline = false;
|
bool _offline = false;
|
||||||
|
bool _loading = false;
|
||||||
TextEditingController _controller = new TextEditingController();
|
TextEditingController _controller = new TextEditingController();
|
||||||
List _suggestions = [];
|
List _suggestions = [];
|
||||||
|
|
||||||
void _submit(BuildContext context, {String query}) {
|
void _submit(BuildContext context, {String query}) async {
|
||||||
if (query != null) _query = query;
|
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(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (context) => SearchResultsScreen(_query, offline: _offline,))
|
MaterialPageRoute(builder: (context) => SearchResultsScreen(_query, offline: _offline,))
|
||||||
);
|
);
|
||||||
|
@ -45,7 +83,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
|
|
||||||
//Load search suggestions
|
//Load search suggestions
|
||||||
Future<List<String>> _loadSuggestions() async {
|
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;
|
String q = _query;
|
||||||
await Future.delayed(Duration(milliseconds: 300));
|
await Future.delayed(Duration(milliseconds: 300));
|
||||||
if (q != _query) return null;
|
if (q != _query) return null;
|
||||||
|
@ -75,7 +113,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
_loadSuggestions();
|
_loadSuggestions();
|
||||||
},
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Search'.i18n
|
labelText: 'Search or paste URL'.i18n
|
||||||
),
|
),
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
onSubmitted: (String s) => _submit(context, query: s),
|
onSubmitted: (String s) => _submit(context, query: s),
|
||||||
|
@ -112,6 +150,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_loading)
|
||||||
|
LinearProgressIndicator(),
|
||||||
Divider(),
|
Divider(),
|
||||||
...List.generate((_suggestions??[]).length, (i) => ListTile(
|
...List.generate((_suggestions??[]).length, (i) => ListTile(
|
||||||
title: Text(_suggestions[i]),
|
title: Text(_suggestions[i]),
|
||||||
|
|
|
@ -6,9 +6,12 @@ import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_material_color_picker/flutter_material_color_picker.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:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:freezer/api/deezer.dart';
|
import 'package:freezer/api/deezer.dart';
|
||||||
|
import 'package:freezer/api/download.dart';
|
||||||
import 'package:freezer/ui/error.dart';
|
import 'package:freezer/ui/error.dart';
|
||||||
|
import 'package:freezer/ui/home_screen.dart';
|
||||||
import 'package:i18n_extension/i18n_widget.dart';
|
import 'package:i18n_extension/i18n_widget.dart';
|
||||||
import 'package:language_pickers/language_pickers.dart';
|
import 'package:language_pickers/language_pickers.dart';
|
||||||
import 'package:language_pickers/languages.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:permission_handler/permission_handler.dart';
|
||||||
import 'package:freezer/translations.i18n.dart';
|
import 'package:freezer/translations.i18n.dart';
|
||||||
import 'package:clipboard/clipboard.dart';
|
import 'package:clipboard/clipboard.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../settings.dart';
|
import '../settings.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
|
@ -30,20 +34,8 @@ class SettingsScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
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() {
|
List<Map<String, String>> _languages() {
|
||||||
|
//Missing language
|
||||||
defaultLanguagesList.add({
|
defaultLanguagesList.add({
|
||||||
'name': 'Filipino',
|
'name': 'Filipino',
|
||||||
'isoCode': 'fil'
|
'isoCode': 'fil'
|
||||||
|
@ -71,6 +63,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
builder: (context) => GeneralSettings()
|
builder: (context) => GeneralSettings()
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('Download Settings'.i18n),
|
||||||
|
leading: Icon(Icons.cloud_download),
|
||||||
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (context) => DownloadsSettings()
|
||||||
|
)),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Appearance'.i18n),
|
title: Text('Appearance'.i18n),
|
||||||
leading: Icon(Icons.color_lens),
|
leading: Icon(Icons.color_lens),
|
||||||
|
@ -132,11 +131,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Divider(),
|
ListTile(
|
||||||
Text(
|
title: Text('About'.i18n),
|
||||||
_about,
|
leading: Icon(Icons.info),
|
||||||
textAlign: TextAlign.center,
|
onTap: () => Navigator.push(context, MaterialPageRoute(
|
||||||
)
|
builder: (context) => CreditsScreen()
|
||||||
|
)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -149,6 +150,10 @@ class AppearanceSettings extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppearanceSettingsState extends State<AppearanceSettings> {
|
class _AppearanceSettingsState extends State<AppearanceSettings> {
|
||||||
|
|
||||||
|
|
||||||
|
ColorSwatch<dynamic> _swatch(int c) => ColorSwatch(c, {500: Color(c)});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
@ -224,8 +229,19 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('Primary color'.i18n),
|
title: Text('Primary color'.i18n),
|
||||||
content: Container(
|
content: Container(
|
||||||
height: 200,
|
height: 240,
|
||||||
child: MaterialColorPicker(
|
child: MaterialColorPicker(
|
||||||
|
colors: [
|
||||||
|
...Colors.primaries,
|
||||||
|
//Logo colors
|
||||||
|
_swatch(0xffeca704),
|
||||||
|
_swatch(0xffbe3266),
|
||||||
|
_swatch(0xff4b2e7e),
|
||||||
|
_swatch(0xff384697),
|
||||||
|
_swatch(0xff0880b5),
|
||||||
|
_swatch(0xff009a85),
|
||||||
|
_swatch(0xff2ba766)
|
||||||
|
],
|
||||||
allowShades: false,
|
allowShades: false,
|
||||||
selectedColor: settings.primaryColor,
|
selectedColor: settings.primaryColor,
|
||||||
onMainColorChange: (ColorSwatch color) {
|
onMainColorChange: (ColorSwatch color) {
|
||||||
|
@ -246,10 +262,13 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Use album art primary color'.i18n),
|
title: Text('Use album art primary color'.i18n),
|
||||||
subtitle: Text('Warning: might be buggy'.i18n),
|
subtitle: Text('Warning: might be buggy'.i18n),
|
||||||
leading: Switch(
|
leading: Container(
|
||||||
|
width: 30.0,
|
||||||
|
child: Checkbox(
|
||||||
value: settings.useArtColor,
|
value: settings.useArtColor,
|
||||||
onChanged: (v) => setState(() => settings.updateUseArtColor(v)),
|
onChanged: (v) => setState(() => settings.updateUseArtColor(v)),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -450,72 +469,86 @@ class _DeezerSettingsState extends State<DeezerSettings> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Log tracks'.i18n),
|
title: Text('Log tracks'.i18n),
|
||||||
subtitle: Text('Send track listen logs to Deezer, enable it for features like Flow to work properly'.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,
|
value: settings.logListen,
|
||||||
onChanged: (bool v) {
|
onChanged: (bool v) {
|
||||||
setState(() => settings.logListen = v);
|
setState(() => settings.logListen = v);
|
||||||
settings.save();
|
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(
|
ListTile(
|
||||||
title: Text('Offline mode'.i18n),
|
title: Text('Proxy'.i18n),
|
||||||
subtitle: Text('Will be overwritten on start.'.i18n),
|
leading: Icon(Icons.vpn_key),
|
||||||
leading: Switch(
|
subtitle: Text(settings.proxyAddress??'Not set'),
|
||||||
value: settings.offlineMode,
|
onTap: () {
|
||||||
onChanged: (bool v) {
|
String _new;
|
||||||
if (v) {
|
|
||||||
setState(() => settings.offlineMode = true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (BuildContext 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(
|
return AlertDialog(
|
||||||
title: Text('Logging in...'.i18n),
|
title: Text('Proxy'.i18n),
|
||||||
content: Row(
|
content: TextField(
|
||||||
mainAxisSize: MainAxisSize.max,
|
onChanged: (String v) => _new = v,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
decoration: InputDecoration(
|
||||||
children: <Widget>[
|
hintText: 'IP:PORT'
|
||||||
CircularProgressIndicator()
|
),
|
||||||
],
|
),
|
||||||
|
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(
|
ListTile(
|
||||||
title: Text('Download path'.i18n),
|
title: Text('Download path'.i18n),
|
||||||
leading: Icon(Icons.folder),
|
leading: Icon(Icons.folder),
|
||||||
|
@ -557,7 +590,7 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
||||||
),
|
),
|
||||||
Container(height: 8.0),
|
Container(height: 8.0),
|
||||||
Text(
|
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(
|
style: TextStyle(
|
||||||
fontSize: 12.0,
|
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(
|
ListTile(
|
||||||
title: Text('Create folders for artist'.i18n),
|
title: Text('Create folders for artist'.i18n),
|
||||||
leading: Switch(
|
leading: Container(
|
||||||
|
width: 30.0,
|
||||||
|
child: Checkbox(
|
||||||
value: settings.artistFolder,
|
value: settings.artistFolder,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() => settings.artistFolder = v);
|
setState(() => settings.artistFolder = v);
|
||||||
|
@ -608,9 +668,12 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Create folders for albums'.i18n),
|
title: Text('Create folders for albums'.i18n),
|
||||||
leading: Switch(
|
leading: Container(
|
||||||
|
width: 30.0,
|
||||||
|
child: Checkbox(
|
||||||
value: settings.albumFolder,
|
value: settings.albumFolder,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() => settings.albumFolder = v);
|
setState(() => settings.albumFolder = v);
|
||||||
|
@ -618,9 +681,12 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Separate albums by discs'.i18n),
|
title: Text('Separate albums by discs'.i18n),
|
||||||
leading: Switch(
|
leading: Container(
|
||||||
|
width: 30.0,
|
||||||
|
child: Checkbox(
|
||||||
value: settings.albumDiscFolder,
|
value: settings.albumDiscFolder,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() => settings.albumDiscFolder = v);
|
setState(() => settings.albumDiscFolder = v);
|
||||||
|
@ -628,9 +694,12 @@ class _GeneralSettingsState extends State<GeneralSettings> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Overwrite already downloaded files'.i18n),
|
title: Text('Overwrite already downloaded files'.i18n),
|
||||||
leading: Switch(
|
leading: Container(
|
||||||
|
width: 30.0,
|
||||||
|
child: Checkbox(
|
||||||
value: settings.overwriteDownload,
|
value: settings.overwriteDownload,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() => settings.overwriteDownload = 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(
|
ListTile(
|
||||||
title: Text('Copy ARL'.i18n),
|
title: Text('Copy ARL'.i18n),
|
||||||
subtitle: Text('Copy userToken/ARL Cookie for use in other apps.'.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
|
name: _fe_analyzer_shared
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "11.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.39.14"
|
version: "0.40.4"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -28,7 +28,7 @@ packages:
|
||||||
name: async
|
name: async
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "2.5.0-nullsafety.1"
|
||||||
audio_service:
|
audio_service:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -49,14 +49,14 @@ packages:
|
||||||
name: boolean_selector
|
name: boolean_selector
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.1.0-nullsafety.1"
|
||||||
build:
|
build:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.5.0"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -77,21 +77,21 @@ packages:
|
||||||
name: build_resolvers
|
name: build_resolvers
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.11"
|
version: "1.4.1"
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.3"
|
||||||
build_runner_core:
|
build_runner_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_runner_core
|
name: build_runner_core
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.1"
|
version: "6.0.3"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -119,14 +119,14 @@ packages:
|
||||||
name: characters
|
name: characters
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.1.0-nullsafety.3"
|
||||||
charcode:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: charcode
|
name: charcode
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.3"
|
version: "1.2.0-nullsafety.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -140,7 +140,7 @@ packages:
|
||||||
name: cli_util
|
name: cli_util
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.4"
|
version: "0.2.0"
|
||||||
clipboard:
|
clipboard:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -154,7 +154,7 @@ packages:
|
||||||
name: clock
|
name: clock
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.1.0-nullsafety.1"
|
||||||
code_builder:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -168,14 +168,14 @@ packages:
|
||||||
name: collection
|
name: collection
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.14.13"
|
version: "1.15.0-nullsafety.3"
|
||||||
connectivity:
|
connectivity:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: connectivity
|
name: connectivity
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.9+2"
|
version: "0.4.9+3"
|
||||||
connectivity_for_web:
|
connectivity_for_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -189,7 +189,7 @@ packages:
|
||||||
name: connectivity_macos
|
name: connectivity_macos
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.0+4"
|
version: "0.1.0+5"
|
||||||
connectivity_platform_interface:
|
connectivity_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -245,7 +245,7 @@ packages:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.6"
|
version: "1.3.7"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -287,7 +287,14 @@ packages:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
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:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -348,7 +355,7 @@ packages:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.4+4"
|
version: "1.4.4+5"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -385,6 +392,13 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fluttericon:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: fluttericon
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.7"
|
||||||
fluttertoast:
|
fluttertoast:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -447,7 +461,7 @@ packages:
|
||||||
name: i18n_extension
|
name: i18n_extension
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.4"
|
version: "1.4.5"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -475,14 +489,14 @@ packages:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.1.0"
|
||||||
json_serializable:
|
json_serializable:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: json_serializable
|
name: json_serializable
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.5.0"
|
||||||
just_audio:
|
just_audio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -517,14 +531,14 @@ packages:
|
||||||
name: matcher
|
name: matcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.8"
|
version: "0.12.10-nullsafety.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.8"
|
version: "1.3.0-nullsafety.3"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -587,14 +601,14 @@ packages:
|
||||||
name: path
|
name: path
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.7.0"
|
version: "1.8.0-nullsafety.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.14"
|
version: "1.6.18"
|
||||||
path_provider_ex:
|
path_provider_ex:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -615,7 +629,7 @@ packages:
|
||||||
name: path_provider_macos
|
name: path_provider_macos
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.4+3"
|
version: "0.0.4+4"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -623,13 +637,20 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
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:
|
pedantic:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pedantic
|
name: pedantic
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.0"
|
version: "1.9.2"
|
||||||
permission_handler:
|
permission_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -657,7 +678,7 @@ packages:
|
||||||
name: plugin_platform_interface
|
name: plugin_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.3"
|
||||||
pointycastle:
|
pointycastle:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -739,14 +760,14 @@ packages:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.6"
|
version: "0.9.7+1"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.7.0"
|
version: "1.8.0-nullsafety.2"
|
||||||
sprintf:
|
sprintf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -774,14 +795,14 @@ packages:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.5"
|
version: "1.10.0-nullsafety.1"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stream_channel
|
name: stream_channel
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.1.0-nullsafety.1"
|
||||||
stream_transform:
|
stream_transform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -795,7 +816,7 @@ packages:
|
||||||
name: string_scanner
|
name: string_scanner
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.1.0-nullsafety.1"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -809,14 +830,14 @@ packages:
|
||||||
name: term_glyph
|
name: term_glyph
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.2.0-nullsafety.1"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.17"
|
version: "0.2.19-nullsafety.2"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -830,7 +851,49 @@ packages:
|
||||||
name: typed_data
|
name: typed_data
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
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:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -844,7 +907,7 @@ packages:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.8"
|
version: "2.1.0-nullsafety.3"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -859,13 +922,20 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.7.3"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xdg_directories
|
name: xdg_directories
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.0"
|
version: "0.1.2"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -874,5 +944,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "2.2.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.9.0 <3.0.0"
|
dart: ">=2.10.0-110 <2.11.0"
|
||||||
flutter: ">=1.20.0 <2.0.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.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 0.4.2+1
|
version: 0.5.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.8.0 <3.0.0"
|
sdk: ">=2.8.0 <3.0.0"
|
||||||
|
@ -61,9 +61,11 @@ dependencies:
|
||||||
flutter_screenutil: ^2.3.0
|
flutter_screenutil: ^2.3.0
|
||||||
marquee: ^1.5.2
|
marquee: ^1.5.2
|
||||||
flutter_cache_manager: ^1.4.1
|
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
|
clipboard: ^0.1.2+8
|
||||||
i18n_extension: ^1.4.4
|
i18n_extension: ^1.4.4
|
||||||
|
fluttericon: ^1.0.7
|
||||||
|
url_launcher: ^5.7.2
|
||||||
|
|
||||||
audio_session: ^0.0.7
|
audio_session: ^0.0.7
|
||||||
audio_service:
|
audio_service:
|
||||||
|
|
Loading…
Reference in New Issue