0.5.0 - Rewritten downloads, many bugfixes

This commit is contained in:
exttex 2020-10-09 20:52:45 +02:00
parent f7cbb09bc1
commit f2f6b202d1
38 changed files with 5176 additions and 1365 deletions

View File

@ -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.

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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"));
}
}
}

View File

@ -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);
}
}

View File

@ -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

67
lib/api/cache.dart Normal file
View File

@ -0,0 +1,67 @@
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/ui/details_screens.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'dart:io';
import 'dart:convert';
part 'cache.g.dart';
Cache cache;
//Cache for miscellaneous things
@JsonSerializable()
class Cache {
//ID's of tracks that are in library
List<String> libraryTracks = [];
//Track ID of logged track, to prevent duplicates
@JsonKey(ignore: true)
String loggedTrackId;
@JsonKey(defaultValue: [])
List<Track> history = [];
//Cache playlist sort type {id: sort}
@JsonKey(defaultValue: {})
Map<String, SortType> playlistSort;
Cache({this.libraryTracks});
//Wrapper to test if track is favorite against cache
bool checkTrackFavorite(Track t) {
if (t.favorite != null && t.favorite) return true;
if (libraryTracks == null || libraryTracks.length == 0) return false;
return libraryTracks.contains(t.id);
}
//Save, load
static Future<String> getPath() async {
return p.join((await getApplicationDocumentsDirectory()).path, 'metacache.json');
}
static Future<Cache> load() async {
File file = File(await Cache.getPath());
//Doesn't exist, create new
if (!(await file.exists())) {
Cache c = Cache();
await c.save();
return c;
}
return Cache.fromJson(jsonDecode(await file.readAsString()));
}
Future save() async {
File file = File(await Cache.getPath());
file.writeAsString(jsonEncode(this.toJson()));
}
//JSON
factory Cache.fromJson(Map<String, dynamic> json) => _$CacheFromJson(json);
Map<String, dynamic> toJson() => _$CacheToJson(this);
}

69
lib/api/cache.g.dart Normal file
View File

@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cache.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Cache _$CacheFromJson(Map<String, dynamic> json) {
return Cache(
libraryTracks:
(json['libraryTracks'] as List)?.map((e) => e as String)?.toList(),
)
..history = (json['history'] as List)
?.map((e) =>
e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList() ??
[]
..playlistSort = (json['playlistSort'] as Map<String, dynamic>)?.map(
(k, e) => MapEntry(k, _$enumDecodeNullable(_$SortTypeEnumMap, e)),
) ??
{};
}
Map<String, dynamic> _$CacheToJson(Cache instance) => <String, dynamic>{
'libraryTracks': instance.libraryTracks,
'history': instance.history,
'playlistSort': instance.playlistSort
?.map((k, e) => MapEntry(k, _$SortTypeEnumMap[e])),
};
T _$enumDecode<T>(
Map<T, dynamic> enumValues,
dynamic source, {
T unknownValue,
}) {
if (source == null) {
throw ArgumentError('A value must be provided. Supported values: '
'${enumValues.values.join(', ')}');
}
final value = enumValues.entries
.singleWhere((e) => e.value == source, orElse: () => null)
?.key;
if (value == null && unknownValue == null) {
throw ArgumentError('`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}');
}
return value ?? unknownValue;
}
T _$enumDecodeNullable<T>(
Map<T, dynamic> enumValues,
dynamic source, {
T unknownValue,
}) {
if (source == null) {
return null;
}
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
}
const _$SortTypeEnumMap = {
SortType.DEFAULT: 'DEFAULT',
SortType.REVERSE: 'REVERSE',
SortType.ALPHABETIC: 'ALPHABETIC',
SortType.ARTIST: 'ARTIST',
};

View File

@ -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': []
});
}
} }

View File

@ -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;
}
}

804
lib/api/download-out.dart Normal file
View File

@ -0,0 +1,804 @@
import 'dart:typed_data';
import 'package:disk_space/disk_space.dart';
import 'package:ext_storage/ext_storage.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:random_string/random_string.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:dio/dio.dart';
import 'package:filesize/filesize.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:io';
import 'dart:async';
import 'deezer.dart';
import '../settings.dart';
import 'definitions.dart';
import '../ui/cached_image.dart';
DownloadManager downloadManager = DownloadManager();
MethodChannel platformChannel = const MethodChannel('f.f.freezer/native');
class DownloadManager {
Database db;
List<Download> queue = [];
String _offlinePath;
Future _download;
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;
bool _cancelNotifications = true;
bool stopped = true;
Future init() async {
//Prepare DB
String dir = await getDatabasesPath();
String path = p.join(dir, 'offline.db');
db = await openDatabase(
path,
version: 1,
onCreate: (Database db, int version) async {
Batch b = db.batch();
//Create tables
b.execute(""" CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, url TEXT, private INTEGER, state INTEGER, trackId TEXT)""");
b.execute("""CREATE TABLE tracks (
id TEXT PRIMARY KEY, title TEXT, album TEXT, artists TEXT, duration INTEGER, albumArt TEXT, trackNumber INTEGER, offline INTEGER, lyrics TEXT, favorite INTEGER)""");
b.execute("""CREATE TABLE albums (
id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER)""");
b.execute("""CREATE TABLE artists (
id TEXT PRIMARY KEY, name TEXT, albums TEXT, topTracks TEXT, picture TEXT, fans INTEGER, albumCount INTEGER, offline INTEGER, library INTEGER)""");
b.execute("""CREATE TABLE playlists (
id TEXT PRIMARY KEY, title TEXT, tracks TEXT, image TEXT, duration INTEGER, userId TEXT, userName TEXT, fans INTEGER, library INTEGER, description TEXT)""");
await b.commit();
}
);
//Prepare folders (/sdcard/Android/data/freezer/data/)
_offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
await Directory(_offlinePath).create(recursive: true);
//Notifications
await _prepareNotifications();
//Restore
List<Map> downloads = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 0");
downloads.forEach((download) => queue.add(Download.fromSQL(download, parseTrack: true)));
}
//Initialize flutter local notification plugin
Future _prepareNotifications() async {
flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
AndroidInitializationSettings androidInitializationSettings = AndroidInitializationSettings('@drawable/ic_logo');
InitializationSettings initializationSettings = InitializationSettings(androidInitializationSettings, null);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
//Show download progress notification, if now/total = null, show intermediate
Future _startProgressNotification() async {
_cancelNotifications = false;
Timer.periodic(Duration(milliseconds: 500), (timer) async {
//Cancel notifications
if (_cancelNotifications) {
flutterLocalNotificationsPlugin.cancel(10);
timer.cancel();
return;
}
//Not downloading
if (this.queue.length <= 0) return;
Download d = queue[0];
//Prepare and show notification
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
'download', 'Download', 'Download',
importance: Importance.Default,
priority: Priority.Default,
showProgress: true,
maxProgress: d.total??1,
progress: d.received??1,
playSound: false,
enableVibration: false,
autoCancel: true,
//ongoing: true, //Allow dismissing
indeterminate: (d.total == null || d.total == d.received),
onlyAlertOnce: true
);
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
await downloadManager.flutterLocalNotificationsPlugin.show(
10,
'Downloading: ${d.track.title}',
(d.state == DownloadState.POST) ? 'Post processing...' : '${filesize(d.received)} / ${filesize(d.total)} (${queue.length} in queue)',
notificationDetails
);
});
}
//Update queue, start new download
void updateQueue() async {
if (_download == null && queue.length > 0 && !stopped) {
_download = queue[0].download(
onDone: () async {
//On download finished
await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]);
queue.removeAt(0);
_download = null;
//Remove notification if no more downloads
if (queue.length == 0) {
_cancelNotifications = true;
}
updateQueue();
}
).catchError((e, st) async {
if (stopped) return;
_cancelNotifications = true;
//Deezer error - track is unavailable
if (queue[0].state == DownloadState.DEEZER_ERROR) {
await db.rawUpdate('UPDATE downloads SET state = 4 WHERE trackId = ?', [queue[0].track.id]);
queue.removeAt(0);
_cancelNotifications = false;
_download = null;
updateQueue();
return;
}
//Clean
_download = null;
stopped = true;
print('Download error: $e\n$st');
queue[0].state = DownloadState.NONE;
//Shift to end
queue.add(queue[0]);
queue.removeAt(0);
//Show error
await _showError();
});
//Show download progress notifications
if (_cancelNotifications == null || _cancelNotifications) _startProgressNotification();
}
}
//Stop downloading and end my life
Future stop() async {
stopped = true;
if (_download != null) {
await queue[0].stop();
}
_download = null;
}
//Start again downloads
Future start() async {
if (_download != null) return;
stopped = false;
updateQueue();
}
//Show error notification
Future _showError() async {
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
'downloadError', 'Download Error', 'Download Error'
);
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
flutterLocalNotificationsPlugin.show(
11, 'Error while downloading!', 'Please restart downloads in the library', notificationDetails
);
}
//Returns all offline tracks
Future<List<Track>> allOfflineTracks() async {
List data = await db.query('tracks', where: 'offline == 1');
List<Track> tracks = [];
//Load track data
for (var t in data) {
tracks.add(await getTrack(t['id']));
}
return tracks;
}
//Get all offline playlists
Future<List<Playlist>> getOfflinePlaylists() async {
List data = await db.query('playlists');
List<Playlist> playlists = [];
//Load playlists
for (var p in data) {
playlists.add(await getPlaylist(p['id']));
}
return playlists;
}
//Get playlist metadata with tracks
Future<Playlist> getPlaylist(String id) async {
if (id == null) return null;
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
if (data.length == 0) return null;
//Load playlist tracks
Playlist p = Playlist.fromSQL(data[0]);
for (int i=0; i<p.tracks.length; i++) {
p.tracks[i] = await getTrack(p.tracks[i].id);
}
return p;
}
//Gets favorites
Future<Playlist> getFavorites() async {
return await getPlaylist('FAVORITES');
}
Future<List<Album>> getOfflineAlbums({List albumsData}) async {
//Load albums
if (albumsData == null) {
albumsData = await db.query('albums', where: 'offline == 1');
}
List<Album> albums = albumsData.map((alb) => Album.fromSQL(alb)).toList();
for(int i=0; i<albums.length; i++) {
albums[i].library = true;
//Load tracks
for(int j=0; j<albums[i].tracks.length; j++) {
albums[i].tracks[j] = await getTrack(albums[i].tracks[j].id, album: albums[i]);
}
//Load artists
List artistsData = await db.rawQuery('SELECT * FROM artists WHERE id IN (${albumsData[i]['artists']})');
albums[i].artists = artistsData.map<Artist>((a) => Artist.fromSQL(a)).toList();
}
return albums;
}
//Get track with metadata from db
Future<Track> getTrack(String id, {Album album, List<Artist> artists}) async {
List tracks = await db.query('tracks', where: 'id == ?', whereArgs: [id]);
if (tracks.length == 0) return null;
Track t = Track.fromSQL(tracks[0]);
//Load album from DB
t.album = album ?? Album.fromSQL((await db.query('albums', where: 'id == ?', whereArgs: [t.album.id]))[0]);
if (artists != null) {
t.artists = artists;
return t;
}
//Load artists from DB
for (int i=0; i<t.artists.length; i++) {
t.artists[i] = Artist.fromSQL(
(await db.query('artists', where: 'id == ?', whereArgs: [t.artists[i].id]))[0]);
}
return t;
}
Future removeOfflineTrack(String id) async {
//Check if track present in albums
List counter = await db.rawQuery('SELECT COUNT(*) FROM albums WHERE tracks LIKE "%$id%"');
if (counter[0]['COUNT(*)'] > 0) return;
//and in playlists
counter = await db.rawQuery('SELECT COUNT(*) FROM playlists WHERE tracks LIKE "%$id%"');
if (counter[0]['COUNT(*)'] > 0) return;
//Remove file
List download = await db.query('downloads', where: 'trackId == ?', whereArgs: [id]);
await File(download[0]['path']).delete();
//Delete from db
await db.delete('tracks', where: 'id == ?', whereArgs: [id]);
await db.delete('downloads', where: 'trackId == ?', whereArgs: [id]);
}
//Delete offline album
Future removeOfflineAlbum(String id) async {
List data = await db.rawQuery('SELECT * FROM albums WHERE id == ? AND offline == 1', [id]);
if (data.length == 0) return;
Map<String, dynamic> album = Map.from(data[0]); //make writable
//Remove DB
album['offline'] = 0;
await db.update('albums', album, where: 'id == ?', whereArgs: [id]);
//Get track ids
List<String> tracks = album['tracks'].split(',');
for (String t in tracks) {
//Remove tracks
await removeOfflineTrack(t);
}
}
Future removeOfflinePlaylist(String id) async {
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
if (data.length == 0) return;
Playlist p = Playlist.fromSQL(data[0]);
//Remove db
await db.delete('playlists', where: 'id == ?', whereArgs: [id]);
//Remove tracks
for(Track t in p.tracks) {
await removeOfflineTrack(t.id);
}
}
//Get path to offline track
Future<String> getOfflineTrackPath(String id) async {
List<Map> tracks = await db.rawQuery('SELECT path FROM downloads WHERE state == 1 AND trackId == ?', [id]);
if (tracks.length < 1) {
return null;
}
Download d = Download.fromSQL(tracks[0]);
return d.path;
}
Future addOfflineTrack(Track track, {private = true, forceStart = true}) async {
//Paths
String path = p.join(_offlinePath, track.id);
if (track.playbackDetails == null) {
//Get track from API if download info missing
track = await deezerAPI.track(track.id);
}
if (!private) {
//Check permissions
if (!(await Permission.storage.request().isGranted)) {
return;
}
//If saving to external
//Save just extension to path, will be generated before download
path = 'mp3';
if (settings.downloadQuality == AudioQuality.FLAC) {
path = 'flac';
}
} else {
//Load lyrics for private
try {
Lyrics l = await deezerAPI.lyrics(track.id);
track.lyrics = l;
} catch (e) {}
}
Download download = Download(track: track, path: path, private: private);
//Database
Batch b = db.batch();
b.insert('downloads', download.toSQL());
b.insert('tracks', track.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore);
if (private) {
//Duplicate check
List<Map> duplicate = await db.rawQuery('SELECT * FROM downloads WHERE trackId == ?', [track.id]);
if (duplicate.length != 0) return;
//Save art
//await imagesDatabase.getImage(track.albumArt.full);
imagesDatabase.saveImage(track.albumArt.full);
//Save to db
b.insert('tracks', track.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
b.insert('albums', track.album.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore);
track.artists.forEach((art) => b.insert('artists', art.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore));
}
await b.commit();
queue.add(download);
if (forceStart) start();
}
Future addOfflineAlbum(Album album, {private = true}) async {
//Get full album from API if tracks are missing
if (album.tracks == null || album.tracks.length == 0) {
album = await deezerAPI.album(album.id);
}
//Update album in database
if (private) {
await db.insert('albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
}
//Save all tracks
for (Track track in album.tracks) {
await addOfflineTrack(track, private: private, forceStart: false);
}
start();
}
//Add offline playlist, can be also used as update
Future addOfflinePlaylist(Playlist playlist, {private = true}) async {
//Load full playlist if missing tracks
if (playlist.tracks == null || playlist.tracks.length != playlist.trackCount) {
playlist = await deezerAPI.fullPlaylist(playlist.id);
}
playlist.library = true;
//To DB
if (private) {
await db.insert('playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace);
}
//Download all tracks
for (Track t in playlist.tracks) {
await addOfflineTrack(t, private: private, forceStart: false);
}
start();
}
Future checkOffline({Album album, Track track, Playlist playlist}) async {
//Check if album/track (TODO: Artist, playlist) is offline
if (track != null) {
List res = await db.query('tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]);
if (res.length == 0) return false;
return true;
}
if (album != null) {
List res = await db.query('albums', where: 'id == ? AND offline == 1', whereArgs: [album.id]);
if (res.length == 0) return false;
return true;
}
if (playlist != null && playlist.id != null) {
List res = await db.query('playlists', where: 'id == ?', whereArgs: [playlist.id]);
if (res.length == 0) return false;
return true;
}
return false;
}
//Offline search
Future<SearchResults> search(String query) async {
SearchResults results = SearchResults(
tracks: [],
albums: [],
artists: [],
playlists: []
);
//Tracks
List tracksData = await db.rawQuery('SELECT * FROM tracks WHERE offline == 1 AND title like "%$query%"');
for (Map trackData in tracksData) {
results.tracks.add(await getTrack(trackData['id']));
}
//Albums
List albumsData = await db.rawQuery('SELECT * FROM albums WHERE offline == 1 AND title like "%$query%"');
results.albums = await getOfflineAlbums(albumsData: albumsData);
//Artists
//TODO: offline artists
//Playlists
List playlists = await db.rawQuery('SELECT * FROM playlists WHERE title like "%$query%"');
for (Map playlist in playlists) {
results.playlists.add(await getPlaylist(playlist['id']));
}
return results;
}
Future<List<Download>> getFinishedDownloads() async {
//Fetch from db
List<Map> data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1 OR downloads.state > 3");
List<Download> downloads = data.map<Download>((d) => Download.fromSQL(d, parseTrack: true)).toList();
return downloads;
}
//Get stats for library screen
Future<List<String>> getStats() async {
//Get offline counts
int trackCount = (await db.rawQuery('SELECT COUNT(*) FROM tracks WHERE offline == 1'))[0]['COUNT(*)'];
int albumCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
int playlistCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
//Free space
double diskSpace = await DiskSpace.getFreeDiskSpace;
//Used space
List<FileSystemEntity> offlineStat = await Directory(_offlinePath).list().toList();
int offlineSize = 0;
for (var fs in offlineStat) {
offlineSize += (await fs.stat()).size;
}
//Return as a list, maybe refactor in future if feature stays
return ([
trackCount.toString(),
albumCount.toString(),
playlistCount.toString(),
filesize(offlineSize),
filesize((diskSpace * 1000000).floor())
]);
}
//Delete download from db
Future removeDownload(Download download) async {
await db.delete('downloads', where: 'trackId == ?', whereArgs: [download.track.id]);
queue.removeWhere((d) => d.track.id == download.track.id);
//TODO: remove files for downloaded
}
//Delete queue
Future clearQueue() async {
while (queue.length > 0) {
if (queue.length == 1) {
if (_download != null) break;
await removeDownload(queue[0]);
return;
}
await removeDownload(queue[1]);
}
}
//Remove non-private downloads
Future cleanDownloadHistory() async {
await db.delete('downloads', where: 'private == 0');
}
}
class Download {
Track track;
String path;
String url;
bool private;
DownloadState state;
String _cover;
//For canceling
IOSink _outSink;
CancelToken _cancel;
StreamSubscription _progressSub;
int received = 0;
int total = 1;
Download({this.track, this.path, this.url, this.private, this.state = DownloadState.NONE});
//Stop download
Future stop() async {
if (_cancel != null) _cancel.cancel();
//if (_outSink != null) _outSink.close();
if (_progressSub != null) _progressSub.cancel();
received = 0;
total = 1;
state = DownloadState.NONE;
}
Future download({onDone}) async {
Dio dio = Dio();
//TODO: Check for internet before downloading
Map rawTrackPublic = {};
Map rawAlbumPublic = {};
if (!this.private && !(this.path.endsWith('.mp3') || this.path.endsWith('.flac'))) {
String ext = this.path;
//Get track details
Map _rawTrackData = await deezerAPI.callApi('song.getListData', params: {'sng_ids': [track.id]});
Map rawTrack = _rawTrackData['results']['data'][0];
this.track = Track.fromPrivateJson(rawTrack);
//RAW Public API call (for genre and other tags)
try {rawTrackPublic = await deezerAPI.callPublicApi('track/${this.track.id}');} catch (e) {rawTrackPublic = {};}
try {rawAlbumPublic = await deezerAPI.callPublicApi('album/${this.track.album.id}');} catch (e) {rawAlbumPublic = {};}
//Global block check
if (rawTrackPublic['available_countries'] != null && rawTrackPublic['available_countries'].length == 0) {
this.state = DownloadState.DEEZER_ERROR;
throw Exception('Download error - not on Deezer');
}
//Get path if public
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
//Download path
this.path = settings.downloadPath ?? (await ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC));
if (settings.artistFolder)
this.path = p.join(this.path, track.artists[0].name.replaceAll(sanitize, ''));
if (settings.albumFolder) {
String folderName = track.album.title.replaceAll(sanitize, '');
//Add disk number
if (settings.albumDiscFolder) folderName += ' - Disk ${track.diskNumber}';
this.path = p.join(this.path, folderName);
}
//Make dirs
await Directory(this.path).create(recursive: true);
//Grab cover
_cover = p.join(this.path, 'cover.jpg');
if (!settings.albumFolder) _cover = p.join(this.path, randomAlpha(12) + '_cover.jpg');
if (!await File(_cover).exists()) {
try {
await dio.download(
this.track.albumArt.full,
_cover,
);
} catch (e) {print('Error downloading cover');}
}
//Create filename
String _filename = settings.downloadFilename;
//Feats filter
String feats = '';
if (track.artists.length > 1) feats = "feat. ${track.artists.sublist(1).map((a) => a.name).join(', ')}";
//Filters
Map<String, String> vars = {
'%artists%': track.artistString.replaceAll(sanitize, ''),
'%artist%': track.artists[0].name.replaceAll(sanitize, ''),
'%title%': track.title.replaceAll(sanitize, ''),
'%album%': track.album.title.replaceAll(sanitize, ''),
'%trackNumber%': track.trackNumber.toString(),
'%0trackNumber%': track.trackNumber.toString().padLeft(2, '0'),
'%feats%': feats
};
//Replace
vars.forEach((key, value) {
_filename = _filename.replaceAll(key, value);
});
_filename += '.$ext';
this.path = p.join(this.path, _filename);
}
//Check if file exists
if (await File(this.path).exists() && !settings.overwriteDownload) {
this.state = DownloadState.DONE;
onDone();
return;
}
//Download
this.state = DownloadState.DOWNLOADING;
//Quality fallback
if (this.url == null)
await _fallback();
//Create download file
File downloadFile = File(this.path + '.ENC');
//Get start position
int start = 0;
if (await downloadFile.exists()) {
FileStat stat = await downloadFile.stat();
start = stat.size;
} else {
//Create file if doesn't exist
await downloadFile.create(recursive: true);
}
//Download
_cancel = CancelToken();
Response response;
try {
response = await dio.get(
this.url,
options: Options(
responseType: ResponseType.stream,
headers: {
'Range': 'bytes=$start-'
},
),
cancelToken: _cancel
);
} on DioError catch (e) {
//Deezer fetch error
if (e.response.statusCode == 403 || e.response.statusCode == 404) {
this.state = DownloadState.DEEZER_ERROR;
}
throw Exception('Download error - Deezer blocked.');
}
//Size
this.total = int.parse(response.headers['Content-Length'][0]) + start;
this.received = start;
//Save
_outSink = downloadFile.openWrite(mode: FileMode.append);
Stream<Uint8List> _data = response.data.stream.asBroadcastStream();
_progressSub = _data.listen((Uint8List c) {
this.received += c.length;
});
//Pipe to file
try {
await _outSink.addStream(_data);
} catch (e) {
await _outSink.close();
throw Exception('Download error');
}
await _outSink.close();
_cancel = null;
this.state = DownloadState.POST;
//Decrypt
await platformChannel.invokeMethod('decryptTrack', {'id': track.id, 'path': path});
//Tag
if (!private) {
//Tag track in native
String year;
if (rawTrackPublic['release_date'] != null && rawTrackPublic['release_date'].length >= 4)
year = rawTrackPublic['release_date'].substring(0, 4);
await platformChannel.invokeMethod('tagTrack', {
'path': path,
'title': track.title,
'album': track.album.title,
'artists': track.artistString,
'artist': track.artists[0].name,
'cover': _cover,
'trackNumber': track.trackNumber,
'diskNumber': track.diskNumber,
'genres': ((rawAlbumPublic['genres']??{})['data']??[]).map((g) => g['name']).toList(),
'year': year,
'bpm': rawTrackPublic['bpm'],
'explicit': (track.explicit??false) ? "1":"0",
'label': rawAlbumPublic['label'],
'albumTracks': rawAlbumPublic['nb_tracks'],
'date': rawTrackPublic['release_date'],
'albumArtist': (rawAlbumPublic['artist']??{})['name']
});
//Rescan android library
await platformChannel.invokeMethod('rescanLibrary', {
'path': path
});
}
//Remove encrypted
await File(path + '.ENC').delete();
if (!settings.albumFolder) await File(_cover).delete();
//Get lyrics
Lyrics lyrics;
try {
lyrics = await deezerAPI.lyrics(track.id);
} catch (e) {}
if (lyrics != null && lyrics.lyrics != null) {
//Create .LRC file
String lrcPath = p.join(p.dirname(path), p.basenameWithoutExtension(path)) + '.lrc';
File lrcFile = File(lrcPath);
String lrcData = '';
//Generate file
lrcData += '[ar:${track.artistString}]\r\n';
lrcData += '[al:${track.album.title}]\r\n';
lrcData += '[ti:${track.title}]\r\n';
for (Lyric l in lyrics.lyrics) {
if (l.lrcTimestamp != null && l.lrcTimestamp != '' && l.text != null)
lrcData += '${l.lrcTimestamp}${l.text}\r\n';
}
lrcFile.writeAsString(lrcData);
}
this.state = DownloadState.DONE;
onDone();
return;
}
Future _fallback({fallback}) async {
//Get quality
AudioQuality quality = private ? settings.offlineQuality : settings.downloadQuality;
if (fallback == AudioQuality.MP3_320) quality = AudioQuality.MP3_128;
if (fallback == AudioQuality.FLAC) {
quality = AudioQuality.MP3_320;
if (this.path.toLowerCase().endsWith('flac'))
this.path = this.path.substring(0, this.path.length - 4) + 'mp3';
}
//No more fallback
if (quality == AudioQuality.MP3_128) {
url = track.getUrl(settings.getQualityInt(quality));
return;
}
//Check
int q = settings.getQualityInt(quality);
try {
Response res = await Dio().head(track.getUrl(q));
if (res.statusCode == 200 || res.statusCode == 206) {
this.url = track.getUrl(q);
return;
}
} catch (e) {}
//Fallback
return _fallback(fallback: quality);
}
//JSON
Map<String, dynamic> toSQL() => {
'trackId': track.id,
'path': path,
'url': url,
'state': state.index,
'private': private?1:0
};
factory Download.fromSQL(Map<String, dynamic> data, {parseTrack = false}) => Download(
track: parseTrack?Track.fromSQL(data):Track(id: data['trackId']),
path: data['path'],
url: data['url'],
state: DownloadState.values[data['state']],
private: data['private'] == 1
);
}
enum DownloadState {
NONE,
DONE,
DOWNLOADING,
POST,
DEEZER_ERROR,
ERROR
}

File diff suppressed because it is too large Load Diff

View File

@ -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));

View File

@ -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;

View File

@ -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": "Βιβλιοθήκη",

View File

@ -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!"
} }
}; };

199
lib/languages/fr_fr.dart Normal file
View File

@ -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."
}
};

193
lib/languages/he_il.dart Normal file
View File

@ -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.": "הורדת השיר נכשלה! התחל מחדש."
}
};

195
lib/languages/hr_hr.dart Normal file
View File

@ -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."
}
};

188
lib/languages/ko_ko.dart Normal file
View File

@ -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.": "트랙을 다운로드하지 못했습니다! 다시 시작하십시오.",
}
};

View File

@ -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": "Радио"

View File

@ -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);
}); });
} }

View File

@ -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 {

View File

@ -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>(

View File

@ -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);
} }

View File

@ -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,

View File

@ -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(() {});
},
),
],
);
},
)
],
)
);
}
} }

View File

@ -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);
},
);
},
),
);
}
}

View File

@ -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();
} }

View File

@ -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();
}, },
) )

View File

@ -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;

View File

@ -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]),

View File

@ -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
),
),
)
],
),
);
}
}

View File

@ -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"

View File

@ -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: