From f2f6b202d164af283dc62fa79473bf15e677f01c Mon Sep 17 00:00:00 2001 From: exttex Date: Fri, 9 Oct 2020 20:52:45 +0200 Subject: [PATCH] 0.5.0 - Rewritten downloads, many bugfixes --- README.md | 10 +- android/app/src/main/AndroidManifest.xml | 74 +- .../app/src/main/java/f/f/freezer/Deezer.java | 352 ++++++ .../src/main/java/f/f/freezer/Download.java | 92 ++ .../java/f/f/freezer/DownloadService.java | 709 +++++++++++ .../java/f/f/freezer/DownloadsDatabase.java | 43 + .../main/java/f/f/freezer/MainActivity.java | 346 ++++-- audio_service | 2 +- just_audio | 2 +- lib/api/cache.dart | 67 + lib/api/cache.g.dart | 69 ++ lib/api/deezer.dart | 77 +- lib/api/definitions.dart | 69 +- lib/api/download-out.dart | 804 ++++++++++++ lib/api/download.dart | 1083 +++++++---------- lib/api/player.dart | 84 +- lib/api/spotify.dart | 1 + lib/languages/el_gr.dart | 48 +- lib/languages/en_us.dart | 31 +- lib/languages/fr_fr.dart | 199 +++ lib/languages/he_il.dart | 193 +++ lib/languages/hr_hr.dart | 195 +++ lib/languages/ko_ko.dart | 188 +++ lib/languages/ru_ru.dart | 54 +- lib/main.dart | 6 +- lib/settings.dart | 18 + lib/settings.g.dart | 10 +- lib/translations.i18n.dart | 14 +- lib/ui/details_screens.dart | 35 +- lib/ui/downloads_screen.dart | 417 ++++--- lib/ui/library.dart | 116 +- lib/ui/login_screen.dart | 14 +- lib/ui/menu.dart | 277 +++-- lib/ui/player_screen.dart | 13 +- lib/ui/search.dart | 46 +- lib/ui/settings_screen.dart | 629 +++++++--- pubspec.lock | 148 ++- pubspec.yaml | 6 +- 38 files changed, 5176 insertions(+), 1365 deletions(-) create mode 100644 android/app/src/main/java/f/f/freezer/Deezer.java create mode 100644 android/app/src/main/java/f/f/freezer/Download.java create mode 100644 android/app/src/main/java/f/f/freezer/DownloadService.java create mode 100644 android/app/src/main/java/f/f/freezer/DownloadsDatabase.java create mode 100644 lib/api/cache.dart create mode 100644 lib/api/cache.g.dart create mode 100644 lib/api/download-out.dart create mode 100644 lib/languages/fr_fr.dart create mode 100644 lib/languages/he_il.dart create mode 100644 lib/languages/hr_hr.dart create mode 100644 lib/languages/ko_ko.dart diff --git a/README.md b/README.md index 67802d9..35c526e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Compile: ``` flutter pub get flutter build apk -``` +``` +NOTE: You have to use own keys, or build debug using `flutter build apk --debug` ## Telegram group https://t.me/freezerandroid @@ -45,7 +46,12 @@ Andrea: Italian Diego Hiro: Portuguese Annexhack: Russian Chino Pacia: Filipino -ArcherDelta & PetFix: Spanish +ArcherDelta & PetFix: Spanish +Shazzaam: Croatian +VIRGIN_KLM: Greek +koreezzz: Korean +Fwwwwwwwwwweze: French +kobyrevah: Hebrew ### just_audio, audio_service This app depends on modified just_audio and audio_service plugins with Deezer support. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e7094bd..68dec5a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,72 +1,90 @@ + - - + FlutterApplication and put your custom class here. + --> - - + + + android:usesCleartextTraffic="true"> + + + + - + to determine the Window background behind the Flutter UI. + --> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - + Flutter's first frame. + --> + android:name="io.flutter.embedding.android.SplashScreenDrawable" + android:resource="@drawable/launch_background" /> + - - + + + - + - - + - - + - + + \ No newline at end of file diff --git a/android/app/src/main/java/f/f/freezer/Deezer.java b/android/app/src/main/java/f/f/freezer/Deezer.java new file mode 100644 index 0000000..d4c8253 --- /dev/null +++ b/android/app/src/main/java/f/f/freezer/Deezer.java @@ -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>> 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 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 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 downloads = new ArrayList<>(); + ArrayList threads = new ArrayList<>(); + ArrayList updateRequests = new ArrayList<>(); + ArrayList 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 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= 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 { + 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 down = new ArrayList<>(); + for (int i=0; i= 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= 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")); + } + } + + +} + diff --git a/android/app/src/main/java/f/f/freezer/DownloadsDatabase.java b/android/app/src/main/java/f/f/freezer/DownloadsDatabase.java new file mode 100644 index 0000000..5d2f06a --- /dev/null +++ b/android/app/src/main/java/f/f/freezer/DownloadsDatabase.java @@ -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); + } +} diff --git a/android/app/src/main/java/f/f/freezer/MainActivity.java b/android/app/src/main/java/f/f/freezer/MainActivity.java index a20935a..2b1c32f 100644 --- a/android/app/src/main/java/f/f/freezer/MainActivity.java +++ b/android/app/src/main/java/f/f/freezer/MainActivity.java @@ -1,43 +1,295 @@ package f.f.freezer; +import android.content.ComponentName; +import android.content.ContentValues; +import android.content.Context; import android.content.Intent; +import android.content.ServiceConnection; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; import android.util.Log; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.jaudiotagger.audio.AudioFile; -import org.jaudiotagger.audio.AudioFileIO; -import org.jaudiotagger.tag.FieldKey; -import org.jaudiotagger.tag.Tag; -import org.jaudiotagger.tag.TagOptionSingleton; -import org.jaudiotagger.tag.datatype.Artwork; -import org.jaudiotagger.tag.flac.FlacTag; -import org.jaudiotagger.tag.id3.ID3v23Tag; -import org.jaudiotagger.tag.id3.valuepair.ImageFormats; -import org.jaudiotagger.tag.reference.PictureTypes; import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; -import java.io.RandomAccessFile; import java.security.MessageDigest; import java.util.ArrayList; -import java.util.function.Function; +import java.util.HashMap; +import java.util.Map; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.GeneratedPluginRegistrant; + +import static f.f.freezer.Deezer.bytesToHex; public class MainActivity extends FlutterActivity { private static final String CHANNEL = "f.f.freezer/native"; + private static final String EVENT_CHANNEL = "f.f.freezer/downloads"; + EventChannel.EventSink eventSink; + boolean serviceBound = false; + Messenger serviceMessenger; + Messenger activityMessenger; + SQLiteDatabase db; + + private static final int SD_PERMISSION_REQUEST_CODE = 42; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + GeneratedPluginRegistrant.registerWith(flutterEngine); + + //Flutter method channel + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL).setMethodCallHandler((((call, result) -> { + + //Add downloads to DB, then refresh service + if (call.method.equals("addDownloads")) { + //TX + db.beginTransaction(); + + ArrayList downloads = call.arguments(); + for (int i=0; i 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 data = new ArrayList<>(); + for (int i=0; i>> 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); - } + */ } diff --git a/audio_service b/audio_service index 73fce99..b268066 160000 --- a/audio_service +++ b/audio_service @@ -1 +1 @@ -Subproject commit 73fce9905f9ffeec0270f7c89b70cd0eaa762fb6 +Subproject commit b268066d26c1cc28183bfc3f0f4ab60d31ebf1f7 diff --git a/just_audio b/just_audio index 884bc7a..ae319b9 160000 --- a/just_audio +++ b/just_audio @@ -1 +1 @@ -Subproject commit 884bc7a26960973f8690aacb14a45f2d303fc676 +Subproject commit ae319b96899fe42a69c57c2f4a17691d90596f98 diff --git a/lib/api/cache.dart b/lib/api/cache.dart new file mode 100644 index 0000000..18cb132 --- /dev/null +++ b/lib/api/cache.dart @@ -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 libraryTracks = []; + + //Track ID of logged track, to prevent duplicates + @JsonKey(ignore: true) + String loggedTrackId; + + @JsonKey(defaultValue: []) + List history = []; + + //Cache playlist sort type {id: sort} + @JsonKey(defaultValue: {}) + Map 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 getPath() async { + return p.join((await getApplicationDocumentsDirectory()).path, 'metacache.json'); + } + + static Future 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 json) => _$CacheFromJson(json); + Map toJson() => _$CacheToJson(this); +} \ No newline at end of file diff --git a/lib/api/cache.g.dart b/lib/api/cache.g.dart new file mode 100644 index 0000000..6274d71 --- /dev/null +++ b/lib/api/cache.g.dart @@ -0,0 +1,69 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Cache _$CacheFromJson(Map 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)) + ?.toList() ?? + [] + ..playlistSort = (json['playlistSort'] as Map)?.map( + (k, e) => MapEntry(k, _$enumDecodeNullable(_$SortTypeEnumMap, e)), + ) ?? + {}; +} + +Map _$CacheToJson(Cache instance) => { + 'libraryTracks': instance.libraryTracks, + 'history': instance.history, + 'playlistSort': instance.playlistSort + ?.map((k, e) => MapEntry(k, _$SortTypeEnumMap[e])), + }; + +T _$enumDecode( + Map 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( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + return null; + } + return _$enumDecode(enumValues, source, unknownValue: unknownValue); +} + +const _$SortTypeEnumMap = { + SortType.DEFAULT: 'DEFAULT', + SortType.REVERSE: 'REVERSE', + SortType.ALPHABETIC: 'ALPHABETIC', + SortType.ARTIST: 'ARTIST', +}; diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index 5a37f10..e473a12 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:freezer/api/cache.dart'; import 'dart:io'; import 'dart:convert'; @@ -47,6 +49,15 @@ class DeezerAPI { return options; } )); + + //Proxy + if (settings.proxyAddress != null && settings.proxyAddress != '' && settings.proxyAddress.length > 9) { + (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) { + client.findProxy = (uri) => "PROXY ${settings.proxyAddress}"; + client.badCertificateCallback = (X509Certificate cert, String host, int port) => true; + }; + } + //Add cookies List cookies = [Cookie('arl', this.arl)]; _cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies); @@ -82,13 +93,13 @@ class DeezerAPI { //Wrapper so it can be globally awaited Future authorize() async { if (_authorizing == null) { - this._authorizing = this._authorize(); + this._authorizing = this.rawAuthorize(); } return _authorizing; } //Authorize, bool = success - Future _authorize() async { + Future rawAuthorize({Function onError}) async { try { Map data = await callApi('deezer.getUserData'); if (data['results']['USER']['USER_ID'] == 0) { @@ -100,7 +111,31 @@ class DeezerAPI { this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID']; return true; } - } catch (e) { return false; } + } catch (e) { + if (onError != null) + onError(e); + print('Login Error (D): ' + e.toString()); + return false; + } + } + + //URL/Link parser + Future parseLink(String url) async { + Uri uri = Uri.parse(url); + //https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID + if (uri.host == 'www.deezer.com' || uri.host == 'deezer.com') { + if (uri.pathSegments.length < 2) return null; + DeezerLinkType type = DeezerLinkResponse.typeFromString(uri.pathSegments[uri.pathSegments.length-2]); + return DeezerLinkResponse(type: type, id: uri.pathSegments[uri.pathSegments.length-1]); + } + //Share URL + if (uri.host == 'deezer.page.link' || uri.host == 'www.deezer.page.link') { + Dio dio = Dio(); + Response res = await dio.head(url, options: RequestOptions( + followRedirects: true + )); + return parseLink('http://deezer.com' + res.realUri.toString()); + } } //Search @@ -168,19 +203,6 @@ class DeezerAPI { //Get playlist with all tracks Future fullPlaylist(String id) async { return await playlist(id, nb: 100000); - - //OLD WORKAROUND - /* - Playlist p = await playlist(id, nb: 200); - for (int i=200; i tracks = await playlistTracksPage(id, i, nb: 200); - p.tracks.addAll(tracks); - i += 200; - continue; - } - return p; - */ } //Add track to favorites @@ -271,7 +293,7 @@ class DeezerAPI { Map data = await callApi('song.getLyrics', params: { 'sng_id': trackId }); - if (data['error'] != null && data['error'].length > 0) return Lyrics().error; + if (data['error'] != null && data['error'].length > 0) return Lyrics.error(); return Lyrics.fromPrivateJson(data['results']); } @@ -318,7 +340,15 @@ class DeezerAPI { //Log song listen to deezer Future logListen(String trackId) async { - await callApi('log.listen', params: {'next_media': {'media': {'id': trackId, 'type': 'song'}}}); + await callApi('log.listen', params: { + 'params': { + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'ts_listen': DateTime.now().millisecondsSinceEpoch, + 'type': 1, + 'stat': {'seek': 0, 'pause': 0, 'sync': 1}, + 'media': {'id': trackId, 'type': 'song', 'format': 'MP3_128'} + } + }); } Future getChannel(String target) async { @@ -406,5 +436,16 @@ class DeezerAPI { }); return data['results']['data'].map((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': [] + }); + } } diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index efdbf43..7a3ba4e 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -10,6 +10,7 @@ import 'package:pointycastle/block/aes_fast.dart'; import 'package:pointycastle/block/modes/ecb.dart'; import 'package:hex/hex.dart'; import 'package:path/path.dart' as p; +import 'package:freezer/translations.i18n.dart'; import 'package:crypto/crypto.dart' as crypto; import 'dart:typed_data'; @@ -30,8 +31,6 @@ class Track { bool offline; Lyrics lyrics; bool favorite; - - //TODO: Not in DB int diskNumber; bool explicit; @@ -102,6 +101,10 @@ class Track { artists = jsonDecode(mi.extras['artists']).map((j) => Artist.fromJson(j)).toList(); } } + List playbackDetails; + if (mi.extras['playbackDetails'] != null) + playbackDetails = jsonDecode(mi.extras['playbackDetails']).map((e) => e.toString()).toList(); + return Track( title: mi.title??mi.displayTitle, artists: artists, @@ -112,7 +115,7 @@ class Track { thumbUrl: mi.extras['thumb'] ), duration: mi.duration, - playbackDetails: null, // So it gets updated from api + playbackDetails: playbackDetails, lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}")) ); } @@ -149,7 +152,9 @@ class Track { 'trackNumber': trackNumber, 'offline': off?1:0, 'lyrics': jsonEncode(lyrics.toJson()), - 'favorite': (favorite??0)?1:0 + 'favorite': (favorite??0)?1:0, + 'diskNumber': diskNumber, + 'explicit': explicit?1:0 }; factory Track.fromSQL(Map data) => Track( id: data['trackId']??data['id'], //If loading from downloads table @@ -163,7 +168,9 @@ class Track { )), offline: (data['offline'] == 1) ? true:false, lyrics: Lyrics.fromJson(jsonDecode(data['lyrics'])), - favorite: (data['favorite'] == 1) ? true:false + favorite: (data['favorite'] == 1) ? true:false, + diskNumber: data['diskNumber'], + explicit: (data['explicit'] == 1) ? true:false ); factory Track.fromJson(Map json) => _$TrackFromJson(json); @@ -186,8 +193,6 @@ class Album { int fans; bool offline; //If the album is offline, or just saved in db as metadata bool library; - - //TODO: Not in DB AlbumType type; String releaseDate; @@ -224,7 +229,9 @@ class Album { 'art': art.full, 'fans': fans, 'offline': off?1:0, - 'library': (library??false)?1:0 + 'library': (library??false)?1:0, + 'type': AlbumType.values.indexOf(type), + 'releaseDate': releaseDate }; factory Album.fromSQL(Map data) => Album( id: data['id'], @@ -238,7 +245,9 @@ class Album { art: ImageDetails(fullUrl: data['art']), fans: data['fans'], offline: (data['offline'] == 1) ? true:false, - library: (data['library'] == 1) ? true:false + library: (data['library'] == 1) ? true:false, + type: AlbumType.values[data['type']], + releaseDate: data['releaseDate'] ); factory Album.fromJson(Map json) => _$AlbumFromJson(json); @@ -256,8 +265,6 @@ class Artist { int fans; bool offline; bool library; - - //TODO: NOT IN DB bool radio; Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library, this.radio}); @@ -296,7 +303,8 @@ class Artist { 'fans': fans, 'albumCount': this.albumCount??(this.albums??[]).length, 'offline': off?1:0, - 'library': (library??false)?1:0 + 'library': (library??false)?1:0, + 'radio': radio?1:0 }; factory Artist.fromSQL(Map data) => Artist( id: data['id'], @@ -311,7 +319,8 @@ class Artist { picture: ImageDetails(fullUrl: data['picture']), fans: data['fans'], offline: (data['offline'] == 1)?true:false, - library: (data['library'] == 1)?true:false + library: (data['library'] == 1)?true:false, + radio: (data['radio'] == 1)?true:false ); factory Artist.fromJson(Map json) => _$ArtistFromJson(json); @@ -456,12 +465,12 @@ class Lyrics { Lyrics({this.id, this.writers, this.lyrics}); - Lyrics get error => Lyrics( - id: id, - writers: writers, + static error() => Lyrics( + id: null, + writers: null, lyrics: [Lyric( offset: Duration(milliseconds: 0), - text: 'Error loading lyrics!' + text: 'Lyrics unavailable, empty or failed to load!'.i18n )] ); @@ -470,7 +479,7 @@ class Lyrics { Lyrics l = Lyrics( id: json['LYRICS_ID'], writers: json['LYRICS_WRITERS'], - lyrics: json['LYRICS_SYNC_JSON'].map((l) => Lyric.fromPrivateJson(l)).toList() + lyrics: (json['LYRICS_SYNC_JSON']??[]).map((l) => Lyric.fromPrivateJson(l)).toList() ); //Clean empty lyrics l.lyrics.removeWhere((l) => l.offset == null); @@ -723,4 +732,28 @@ enum RepeatType { NONE, LIST, TRACK +} + +enum DeezerLinkType { + TRACK, + ALBUM, + ARTIST, + PLAYLIST +} + +class DeezerLinkResponse { + DeezerLinkType type; + String id; + + DeezerLinkResponse({this.type, this.id}); + + //String to DeezerLinkType + static typeFromString(String t) { + t = t.toLowerCase().trim(); + if (t == 'album') return DeezerLinkType.ALBUM; + if (t == 'artist') return DeezerLinkType.ARTIST; + if (t == 'playlist') return DeezerLinkType.PLAYLIST; + if (t == 'track') return DeezerLinkType.TRACK; + return null; + } } \ No newline at end of file diff --git a/lib/api/download-out.dart b/lib/api/download-out.dart new file mode 100644 index 0000000..87286a6 --- /dev/null +++ b/lib/api/download-out.dart @@ -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 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 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> allOfflineTracks() async { + List data = await db.query('tracks', where: 'offline == 1'); + List tracks = []; + //Load track data + for (var t in data) { + tracks.add(await getTrack(t['id'])); + } + return tracks; + } + + //Get all offline playlists + Future> getOfflinePlaylists() async { + List data = await db.query('playlists'); + List playlists = []; + //Load playlists + for (var p in data) { + playlists.add(await getPlaylist(p['id'])); + } + return playlists; + } + + //Get playlist metadata with tracks + Future 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 getFavorites() async { + return await getPlaylist('FAVORITES'); + } + + Future> getOfflineAlbums({List albumsData}) async { + //Load albums + if (albumsData == null) { + albumsData = await db.query('albums', where: 'offline == 1'); + } + List albums = albumsData.map((alb) => Album.fromSQL(alb)).toList(); + for(int i=0; i((a) => Artist.fromSQL(a)).toList(); + } + return albums; + } + + //Get track with metadata from db + Future getTrack(String id, {Album album, List 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 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 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 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 getOfflineTrackPath(String id) async { + List 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 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 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> getFinishedDownloads() async { + //Fetch from db + List data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1 OR downloads.state > 3"); + List downloads = data.map((d) => Download.fromSQL(d, parseTrack: true)).toList(); + return downloads; + } + + //Get stats for library screen + Future> 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 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 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 _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 toSQL() => { + 'trackId': track.id, + 'path': path, + 'url': url, + 'state': state.index, + 'private': private?1:0 + }; + factory Download.fromSQL(Map 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 +} \ No newline at end of file diff --git a/lib/api/download.dart b/lib/api/download.dart index 87286a6..35f87e4 100644 --- a/lib/api/download.dart +++ b/lib/api/download.dart @@ -1,426 +1,369 @@ -import 'dart:typed_data'; +import 'dart:async'; 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:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:freezer/settings.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; import 'package:permission_handler/permission_handler.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:freezer/translations.i18n.dart'; import 'dart:io'; -import 'dart:async'; -import 'deezer.dart'; -import '../settings.dart'; -import 'definitions.dart'; -import '../ui/cached_image.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:sqflite/sql.dart'; DownloadManager downloadManager = DownloadManager(); -MethodChannel platformChannel = const MethodChannel('f.f.freezer/native'); class DownloadManager { - Database db; - List queue = []; - String _offlinePath; - Future _download; - FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; - bool _cancelNotifications = true; + //Platform channels + static MethodChannel platform = MethodChannel('f.f.freezer/native'); + static EventChannel eventChannel = EventChannel('f.f.freezer/downloads'); - bool stopped = true; + bool running = false; + int queueSize = 0; + + StreamController serviceEvents = StreamController.broadcast(); + String offlinePath; + Database db; + + //Start/Resume downloads + Future start() async { + await updateServiceSettings(); + await platform.invokeMethod('start'); + } + + //Stop/Pause downloads + Future stop() async { + await platform.invokeMethod('stop'); + } Future init() async { - //Prepare DB - String dir = await getDatabasesPath(); - String path = p.join(dir, 'offline.db'); + //Remove old DB + File oldDbFile = File(p.join((await getDatabasesPath()), 'offline.db')); + if (await oldDbFile.exists()) { + await oldDbFile.delete(); + } + + String dbPath = p.join((await getDatabasesPath()), 'offline2.db'); + //Open db db = await openDatabase( - path, + dbPath, 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 ( + //Create tables, if doesn't exit + 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, diskNumber INTEGER, explicit INTEGER)"""); + b.execute("""CREATE TABLE Albums ( + id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER, type INTEGER, releaseDate TEXT)"""); + 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, radio 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(); + //Create offline directory + offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/'); + await Directory(offlinePath).create(recursive: true); - //Restore - List 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))); - } + //Update settings + await updateServiceSettings(); - //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; + //Listen to state change event + eventChannel.receiveBroadcastStream().listen((e) { + if (e['action'] == 'onStateChange') { + running = e['running']; + queueSize = e['queueSize']; } - //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 - ); + + //Forward + serviceEvents.add(e); }); + + await platform.invokeMethod('loadDownloads'); } - //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; + //Get all downloads from db + Future> getDownloads() async { + List raw = await platform.invokeMethod('getDownloads'); + return raw.map((d) => Download.fromJson(d)).toList(); + } - //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(); + //Insert track and metadata to DB + Future _addTrackToDB(Batch batch, Track track, bool overwriteTrack) async { + batch.insert('Tracks', track.toSQL(off: true), conflictAlgorithm: overwriteTrack?ConflictAlgorithm.replace:ConflictAlgorithm.ignore); + batch.insert('Albums', track.album.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore); + //Artists + for (Artist a in track.artists) { + batch.insert('Artists', a.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore); } + return batch; } - //Stop downloading and end my life - Future stop() async { - stopped = true; - if (_download != null) { - await queue[0].stop(); + Future addOfflineTrack(Track track, {private = true}) async { + //Permission + if (!private && !(await checkPermission())) return; + + //Add to DB + if (private) { + Batch b = db.batch(); + b = await _addTrackToDB(b, track, true); + await b.commit(); } - _download = null; + + //Get path + String path = _generatePath(track, private); + await platform.invokeMethod('addDownloads', [await Download.jsonFromTrack(track, path, private: private)]); + await start(); } - //Start again downloads - Future start() async { - if (_download != null) return; - stopped = false; - updateQueue(); + Future addOfflineAlbum(Album album, {private = true}) async { + //Permission + if (!private && !(await checkPermission())) return; + + //Get from API if no tracks + if (album.tracks == null || album.tracks.length == 0) { + album = await deezerAPI.album(album.id); + } + + //Add to DB + if (private) { + Batch b = db.batch(); + b.insert('Albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace); + for (Track t in album.tracks) { + b = await _addTrackToDB(b, t, false); + } + await b.commit(); + } + + //Create downloads + List out = []; + for (Track t in album.tracks) { + out.add(await Download.jsonFromTrack(t, _generatePath(t, private), private: private)); + } + await platform.invokeMethod('addDownloads', out); + await start(); } - //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 - ); + Future addOfflinePlaylist(Playlist playlist, {private = true}) async { + //Permission + if (!private && !(await checkPermission())) return; + + //Get tracks if missing + if (playlist.tracks == null || playlist.tracks.length < playlist.trackCount) { + playlist = await deezerAPI.fullPlaylist(playlist.id); + } + + //Add to DB + if (private) { + Batch b = db.batch(); + b.insert('Playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace); + for (Track t in playlist.tracks) { + b = await _addTrackToDB(b, t, false); + } + await b.commit(); + } + + //Generate downloads + List out = []; + for (int i=0; i getOfflineTrack(String id, {Album album, List artists}) async { + List tracks = await db.query('Tracks', where: 'id == ?', whereArgs: [id]); + if (tracks.length == 0) return null; + Track track = Track.fromSQL(tracks[0]); + + //Get album + if (album == null) { + List rawAlbums = await db.query('Albums', where: 'id == ?', whereArgs: [track.album.id]); + if (rawAlbums.length > 0) + track.album = Album.fromSQL(rawAlbums[0]); + } else { + track.album = album; + } + + //Get artists + if (artists == null) { + List newArtists = []; + for (Artist artist in track.artists) { + List rawArtist = await db.query('Artists', where: 'id == ?', whereArgs: [artist.id]); + if (rawArtist.length > 0) + newArtists.add(Artist.fromSQL(rawArtist[0])); + } + if (newArtists.length > 0) + track.artists = newArtists; + } else { + track.artists = artists; + } + return track; + } + + //Get offline library tracks + Future> getOfflineTracks() async { + List rawTracks = await db.query('Tracks', where: 'library == 1 AND offline == 1', columns: ['id']); + List out = []; + //Load track meta individually + for (Map rawTrack in rawTracks) { + out.add(await getOfflineTrack(rawTrack['id'])); + } + return out; + } + + //Get all offline available tracks Future> allOfflineTracks() async { - List data = await db.query('tracks', where: 'offline == 1'); - List tracks = []; - //Load track data - for (var t in data) { - tracks.add(await getTrack(t['id'])); + List rawTracks = await db.query('Tracks', where: 'offline == 1', columns: ['id']); + List out = []; + //Load track meta individually + for (Map rawTrack in rawTracks) { + out.add(await getOfflineTrack(rawTrack['id'])); } - return tracks; + return out; + } + + //Get all offline albums + Future> getOfflineAlbums() async { + List rawAlbums = await db.query('Albums', where: 'offline == 1', columns: ['id']); + List out = []; + //Load each album + for (Map rawAlbum in rawAlbums) { + out.add(await getOfflineAlbum(rawAlbum['id'])); + } + return out; + } + + //Get offline album with meta + Future getOfflineAlbum(String id) async { + List rawAlbums = await db.query('Albums', where: 'id == ?', whereArgs: [id]); + if (rawAlbums.length == 0) return null; + Album album = Album.fromSQL(rawAlbums[0]); + + List tracks = []; + //Load tracks + for (int i=0; i artists = []; + for (int i=0; i getOfflineArtist(String id) async { + List rawArtists = await db.query("Artists", where: 'id == ?', whereArgs: [id]); + if (rawArtists.length == 0) return null; + return Artist.fromSQL(rawArtists[0]); } //Get all offline playlists Future> getOfflinePlaylists() async { - List data = await db.query('playlists'); - List playlists = []; - //Load playlists - for (var p in data) { - playlists.add(await getPlaylist(p['id'])); + List rawPlaylists = await db.query('Playlists', columns: ['id']); + List out = []; + for (Map rawPlaylist in rawPlaylists) { + out.add(await getPlaylist(rawPlaylist['id'])); } - return playlists; + return out; } - //Get playlist metadata with tracks + //Get offline playlist Future 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 tracks = []; + for (Track t in playlist.tracks) { + tracks.add(await getOfflineTrack(t.id)); } - return p; + playlist.tracks = tracks; + return playlist; } - //Gets favorites - Future getFavorites() async { - return await getPlaylist('FAVORITES'); - } - - Future> getOfflineAlbums({List albumsData}) async { - //Load albums - if (albumsData == null) { - albumsData = await db.query('albums', where: 'offline == 1'); - } - List albums = albumsData.map((alb) => Album.fromSQL(alb)).toList(); - for(int i=0; i tracks) async { + for (Track t in tracks) { + //Check if library + List rawTrack = await db.query('Tracks', where: 'id == ?', whereArgs: [t.id], columns: ['favorite']); + if (rawTrack.length > 0 && rawTrack[0]['favorite'] == 0) { + //Count occurrences in playlists and albums + List albums = await db.rawQuery('SELECT (id) FROM Albums WHERE tracks LIKE "%${t.id}%"'); + List playlists = await db.rawQuery('SELECT (id) FROM Playlists WHERE tracks LIKE "%${t.id}%"'); + if (albums.length + playlists.length == 0) { + //Safe to remove + await db.delete('Tracks', where: 'id == ?', whereArgs: [t.id]); + //Remove file + try { + File(p.join(offlinePath, t.id)).delete(); + } catch (e) { + print(e); + } + } } - //Load artists - List artistsData = await db.rawQuery('SELECT * FROM artists WHERE id IN (${albumsData[i]['artists']})'); - albums[i].artists = artistsData.map((a) => Artist.fromSQL(a)).toList(); } - return albums; } - //Get track with metadata from db - Future getTrack(String id, {Album album, List 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 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 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 tracks = album['tracks'].split(','); - for (String t in tracks) { - //Remove tracks - await removeOfflineTrack(t); - } + //Get album + List rawAlbums = await db.query('Albums', where: 'id == ?', whereArgs: [id]); + if (rawAlbums.length == 0) return; + Album album = Album.fromSQL(rawAlbums[0]); + //Remove album + await db.delete('Albums', where: 'id == ?', whereArgs: [id]); + //Remove tracks + await removeOfflineTracks(album.tracks); } 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); - } + //Fetch playlist + List rawPlaylists = await db.query('Playlists', where: 'id == ?', whereArgs: [id]); + if (rawPlaylists.length == 0) return; + Playlist playlist = Playlist.fromSQL(rawPlaylists[0]); + //Remove playlist + await db.delete('Playlists', where: 'id == ?', whereArgs: [id]); + await removeOfflineTracks(playlist.tracks); } - //Get path to offline track - Future getOfflineTrackPath(String id) async { - List 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 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 + //Check if album, track or playlist is offline + Future checkOffline({Album album, Track track, Playlist playlist}) async { + //Track if (track != null) { - List res = await db.query('tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]); + List res = await db.query('Tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]); if (res.length == 0) return false; return true; } - + //Album if (album != null) { - List res = await db.query('albums', where: 'id == ? AND offline == 1', whereArgs: [album.id]); + 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]); + //Playlist + if (playlist != null) { + List res = await db.query('Playlists', where: 'id == ?', whereArgs: [playlist.id]); if (res.length == 0) return false; return true; } @@ -429,22 +372,17 @@ class DownloadManager { //Offline search Future search(String query) async { - SearchResults results = SearchResults( - tracks: [], - albums: [], - artists: [], - playlists: [] - ); + 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'])); + results.tracks.add(await getOfflineTrack(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 + List albumsData = await db.rawQuery('SELECT (id) FROM albums WHERE offline == 1 AND title like "%$query%"'); + for (Map rawAlbum in albumsData) { + results.albums.add(await getOfflineAlbum(rawAlbum['id'])); + } //Playlists List playlists = await db.rawQuery('SELECT * FROM playlists WHERE title like "%$query%"'); for (Map playlist in playlists) { @@ -453,11 +391,47 @@ class DownloadManager { return results; } - Future> getFinishedDownloads() async { - //Fetch from db - List data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1 OR downloads.state > 3"); - List downloads = data.map((d) => Download.fromSQL(d, parseTrack: true)).toList(); - return downloads; + //Sanitize filename + String sanitize(String input) { + RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]'); + return input.replaceAll(sanitize, ''); + } + + //Generate track download path + String _generatePath(Track track, bool private, {String playlistName, int playlistTrackNumber}) { + String path; + if (private) { + path = p.join(offlinePath, track.id); + } else { + //Download path + path = settings.downloadPath; + + if (settings.playlistFolder && playlistName != null) + path = p.join(path, sanitize(playlistName)); + + if (settings.artistFolder) + path = p.join(path, sanitize(track.artistString)); + + //Album folder / with disk number + if (settings.albumFolder) { + if (settings.albumDiscFolder) { + path = p.join(path, sanitize(track.album.title) + ' - Disk ' + track.diskNumber.toString()); + } else { + path = p.join(path, sanitize(track.album.title)); + } + } + //Final path + path = p.join(path, settings.downloadFilename); + //Playlist track number variable (not accessible in service) + if (playlistTrackNumber != null) { + path = path.replaceAll('%playlistTrackNumber%', playlistTrackNumber.toString()); + path = path.replaceAll('%0playlistTrackNumber%', playlistTrackNumber.toString().padLeft(2, '0')); + } else { + path = path.replaceAll('%playlistTrackNumber%', ''); + path = path.replaceAll('%0playlistTrackNumber%', ''); + } + } + return path; } //Get stats for library screen @@ -468,15 +442,13 @@ class DownloadManager { int playlistCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)']; //Free space double diskSpace = await DiskSpace.getFreeDiskSpace; - //Used space - List offlineStat = await Directory(_offlinePath).list().toList(); + List 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 in list, //TODO: Make into class in future return ([ trackCount.toString(), albumCount.toString(), @@ -486,319 +458,114 @@ class DownloadManager { ]); } - //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 + //Send settings to download service + Future updateServiceSettings() async { + await platform.invokeMethod('updateSettings', settings.getServiceSettings()); } - //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]); + //Check storage permission + Future checkPermission() async { + if (await Permission.storage.request().isGranted) { + return true; + } else { + Fluttertoast.showToast( + msg: 'Storage permission denied!'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM + ); + return false; } } - //Remove non-private downloads - Future cleanDownloadHistory() async { - await db.delete('downloads', where: 'private == 0'); + //Remove download from queue/finished + Future removeDownload(int id) async { + await platform.invokeMethod('removeDownload', {'id': id}); + } + + //Restart failed downloads + Future retryDownloads() async { + await platform.invokeMethod('retryDownloads'); + } + + //Delete downloads by state + Future removeDownloads(DownloadState state) async { + await platform.invokeMethod('removeDownloads', {'state': DownloadState.values.indexOf(state)}); } } class Download { - Track track; + int id; String path; - String url; bool private; + String trackId; + String md5origin; + String mediaVersion; + String title; + String image; + int quality; + //Dynamic DownloadState state; - String _cover; + int received; + int filesize; - //For canceling - IOSink _outSink; - CancelToken _cancel; - StreamSubscription _progressSub; + Download({this.id, this.path, this.private, this.trackId, this.md5origin, this.mediaVersion, + this.title, this.image, this.state, this.received, this.filesize, this.quality}); - 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; + //Get progress between 0 - 1 + double get progress { + return ((received.toDouble()??0.0)/(filesize.toDouble()??1.0)).toDouble(); } - 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 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 _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; + factory Download.fromJson(Map data) { + return Download( + path: data['path'], + image: data['image'], + private: data['private'], + trackId: data['trackId'], + id: data['id'], + state: DownloadState.values[data['state']], + title: data['title'], + quality: data['quality'] + ); } - 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); + //Change values from "update json" + void updateFromJson(Map data) { + this.quality = data['quality']; + this.received = data['received']??0; + this.state = DownloadState.values[data['state']]; + //Prevent null division later + this.filesize = ((data['filesize']??0) <= 0) ? 1 : (data['filesize']??1); + } + + //Track to download JSON for service + static Future jsonFromTrack(Track t, String path, {private = true}) async { + //Get download info + if (t.playbackDetails == null || t.playbackDetails == []) { + t = await deezerAPI.track(t.id); + } + return { + "private": private, + "trackId": t.id, + "md5origin": t.playbackDetails[0], + "mediaVersion": t.playbackDetails[1], + "quality": private + ? settings.getQualityInt(settings.offlineQuality) + : settings.getQualityInt(settings.downloadQuality), + "title": t.title, + "path": path, + "image": t.albumArt.thumb + }; } - - //JSON - Map toSQL() => { - 'trackId': track.id, - 'path': path, - 'url': url, - 'state': state.index, - 'private': private?1:0 - }; - factory Download.fromSQL(Map 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 - ); } +//Has to be same order as in java enum DownloadState { NONE, - DONE, DOWNLOADING, POST, + DONE, DEEZER_ERROR, ERROR } \ No newline at end of file diff --git a/lib/api/player.dart b/lib/api/player.dart index eac704a..ba2b7c6 100644 --- a/lib/api/player.dart +++ b/lib/api/player.dart @@ -1,6 +1,9 @@ +import 'dart:math'; + import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/ui/android_auto.dart'; import 'package:just_audio/just_audio.dart'; @@ -21,6 +24,7 @@ PlayerHelper playerHelper = PlayerHelper(); class PlayerHelper { StreamSubscription _customEventSubscription; + StreamSubscription _mediaItemSubscription; StreamSubscription _playbackStateStreamSubscription; QueueSource queueSource; LoopMode repeatType = LoopMode.off; @@ -65,9 +69,26 @@ class PlayerHelper { //Log song (if allowed) if (event == null) return; if (event.processingState == AudioProcessingState.ready && event.playing) { - if (settings.logListen) deezerAPI.logListen(AudioService.currentMediaItem.id); + if (settings.logListen) { + //Check if duplicate + if (cache.loggedTrackId == AudioService.currentMediaItem.id) return; + cache.loggedTrackId = AudioService.currentMediaItem.id; + deezerAPI.logListen(AudioService.currentMediaItem.id); + } } }); + _mediaItemSubscription = AudioService.currentMediaItemStream.listen((event) { + //Save queue + AudioService.customAction('saveQueue'); + + //Add to history + if (event == null) return; + if (cache.history == null) cache.history = []; + if (cache.history.length > 0 && cache.history.last.id == event.id) return; + cache.history.add(Track.fromMediaItem(event)); + cache.save(); + }); + //Start audio_service startService(); } @@ -79,7 +100,7 @@ class PlayerHelper { androidEnableQueue: true, androidStopForegroundOnPause: false, androidNotificationOngoing: false, - androidNotificationClickStartsActivity: true, + androidNotificationClickStartsActivity: false, androidNotificationChannelDescription: 'Freezer', androidNotificationChannelName: 'Freezer', androidNotificationIcon: 'drawable/ic_logo', @@ -110,6 +131,7 @@ class PlayerHelper { Future onExit() async { _customEventSubscription.cancel(); _playbackStateStreamSubscription.cancel(); + _mediaItemSubscription.cancel(); } //Replace queue, play specified track id @@ -256,6 +278,13 @@ class AudioPlayerTask extends BackgroundAudioTask { }); //Update state on all clients on change _eventSub = _player.playbackEventStream.listen((event) { + //Quality string + if (_queueIndex != -1 && _queueIndex < _queue.length) { + Map extras = mediaItem.extras; + extras['qualityString'] = event.qualityString??''; + _queue[_queueIndex] = mediaItem.copyWith(extras: extras); + } + //Update _broadcastState(); }); _player.processingStateStream.listen((state) { @@ -296,6 +325,7 @@ class AudioPlayerTask extends BackgroundAudioTask { //Skip in player await _player.seek(Duration.zero, index: newIndex); + _queueIndex = newIndex; _skipState = null; onPlay(); } @@ -327,6 +357,40 @@ class AudioPlayerTask extends BackgroundAudioTask { @override Future onSeekBackward(bool begin) async => _seekContinuously(begin, -1); + @override + Future 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 onSkipToPrevious() async { + if (_queueIndex == 0) return; + //Update buffering state + _skipState = AudioProcessingState.skippingToPrevious; + _queueIndex--; + await _player.seekToPrevious(); + _skipState = null; + } + @override Future> onLoadChildren(String parentMediaId) async { AudioServiceBackground.sendCustomEvent({ @@ -417,12 +481,16 @@ class AudioPlayerTask extends BackgroundAudioTask { this._queue = q; AudioServiceBackground.setQueue(_queue); //Load + _queueIndex = 0; await _loadQueue(); - await _player.seek(Duration.zero, index: 0); + //await _player.seek(Duration.zero, index: 0); } //Load queue to just_audio Future _loadQueue() async { + //Don't reset queue index by starting player + int qi = _queueIndex; + List sources = []; for(int i=0; i<_queue.length; i++) { sources.add(await _mediaItemToAudioSource(_queue[i])); @@ -432,9 +500,11 @@ class AudioPlayerTask extends BackgroundAudioTask { //Load in just_audio try { await _player.load(_audioSource); + await _player.seek(Duration.zero, index: qi); } catch (e) { //Error loading tracks } + _queueIndex = qi; AudioServiceBackground.setMediaItem(mediaItem); } @@ -523,13 +593,14 @@ class AudioPlayerTask extends BackgroundAudioTask { //Export queue to JSON Future _saveQueue() async { + if (_queueIndex == 0 && _queue.length == 0) return; + String path = await _getQueuePath(); File f = File(path); - //Create if doesnt exist + //Create if doesn't exist if (! await File(path).exists()) { f = await f.create(); } - Map data = { 'index': _queueIndex, 'queue': _queue.map>((mi) => mi.toJson()).toList(), @@ -552,7 +623,7 @@ class AudioPlayerTask extends BackgroundAudioTask { if (_queue != null) { await AudioServiceBackground.setQueue(_queue); await _loadQueue(); - AudioServiceBackground.setMediaItem(mediaItem); + await AudioServiceBackground.setMediaItem(mediaItem); } } //Send restored queue source to ui @@ -568,7 +639,6 @@ class AudioPlayerTask extends BackgroundAudioTask { //-1 == play next if (index == -1) index = _queueIndex + 1; - _queue.insert(index, mi); await AudioServiceBackground.setQueue(_queue); await _audioSource.insert(index, await _mediaItemToAudioSource(mi)); diff --git a/lib/api/spotify.dart b/lib/api/spotify.dart index 7a70674..647f977 100644 --- a/lib/api/spotify.dart +++ b/lib/api/spotify.dart @@ -48,6 +48,7 @@ class SpotifyAPI { SpotifyPlaylist playlist = SpotifyPlaylist.fromJson(data); return playlist; } + Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false}) async { doneImporting = false; diff --git a/lib/languages/el_gr.dart b/lib/languages/el_gr.dart index ae8eff9..aef355e 100644 --- a/lib/languages/el_gr.dart +++ b/lib/languages/el_gr.dart @@ -1,10 +1,16 @@ -const language_gr_el = { - "gr_el": { +/* + +Translated by: VIRGIN_KLM + + */ + +const language_el_gr = { + "el_gr": { "Home": "Αρχική", "Search": "Αναζήτηση", "Library": "Βιβλιοθήκη", "Offline mode, can't play flow or smart track lists.": - "Λειτουργία εκτός σύνδεσης, δεν είναι δυνατή η αναπαραγωγή flow ή έξυπνων λιστών κομματιών.", + "Λειτουργία εκτός σύνδεσης, δεν είναι δυνατή η αναπαραγωγή flow ή έξυπνων λιστών κομματιών.", "Added to library": "Προστέθηκε στη βιβλιοθήκη", "Download": "Λήψη", "Disk": "Δίσκος", @@ -23,30 +29,30 @@ const language_gr_el = { "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 κομματιών", + "Αυτήν τη στιγμή υποστηρίζεται μόνο το 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": "Album", "Artists": "Καλλιτέχνες", @@ -64,24 +70,24 @@ const language_gr_el = { "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.": - "Σφάλμα σύνδεσης! Ελέγξτε το token και τη σύνδεσή σας στο δίκτυο και δοκιμάστε ξανά.", + "Σφάλμα σύνδεσης! Ελέγξτε το token και τη σύνδεσή σας στο δίκτυο και δοκιμάστε ξανά.", "Dismiss": "Απόρριψη", "Welcome to": "Καλωσήρθατε στο", "Please login using your Deezer account.": - "Συνδεθείτε χρησιμοποιώντας τον λογαριασμό σας στο Deezer.", + "Συνδεθείτε χρησιμοποιώντας τον λογαριασμό σας στο Deezer.", "Login using browser": "Σύνδεση χρησιμοποιώντας το πρόγραμμα περιήγησης", "Login using token": "Σύνδεση χρησιμοποιώντας token", "Enter ARL": "Εισαγωγή ARL", "Token (ARL)": "Token (ARL)", "Save": "Αποθήκευση", "If you don't have account, you can register on deezer.com for free.": - "Εάν δεν έχετε λογαριασμό, μπορείτε να εγγραφείτε δωρεάν στο deezer.com.", + "Εάν δεν έχετε λογαριασμό, μπορείτε να εγγραφείτε δωρεάν στο deezer.com.", "Open in browser": "Ανοιγμα σε πρόγραμμα περιήγησης", "By using this app, you don't agree with the Deezer ToS": - "Χρησιμοποιώντας αυτήν την εφαρμογή, δεν συμφωνείτε με τους κανονισμούς χρήσης Deezer", + "Χρησιμοποιώντας αυτήν την εφαρμογή, δεν συμφωνείτε με τους κανονισμούς χρήσης Deezer", "Play next": "Παίξε αμέσως μετά", "Add to queue": "Προσθήκη στην ουρά", "Add track to favorites": "Προσθήκη κομμάτι στα αγαπημένα", @@ -140,17 +146,17 @@ const language_gr_el = { "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", + "Αποστολή αρχείων καταγραφής ακρόασης στο Deezer, ενεργοποιήστε το για ορθή λειτουργία υπηρεσιών όπως το Flow", "Offline mode": "Λειτουργία εκτός σύνδεσης", "Will be overwritten on start.": "Θα αντικατασταθεί κατά την εκκίνηση.", "Error logging in, check your internet connections.": - "Σφάλμα σύνδεσης, ελέγξτε την σύνδεσή σας στο Δίκτυο.", + "Σφάλμα σύνδεσης, ελέγξτε την σύνδεσή σας στο Δίκτυο.", "Logging in...": "Σύνδεση...", "Download path": "Διαδρομή λήψεων", "Downloads naming": "Ονομασία λήψεων", @@ -164,11 +170,11 @@ const language_gr_el = { "Overwrite already downloaded files": "Αντικατάσταση ήδη ληφθέντων αρχείων", "Copy ARL": "Αντιγραφή ARL", "Copy userToken/ARL Cookie for use in other apps.": - "Αντιγραφή userToken/ARL Cookie για χρήση σε άλλες εφαρμογές.", + "Αντιγραφή userToken/ARL Cookie για χρήση σε άλλες εφαρμογές.", "Copied": "Αντιγράφηκε", "Log out": "Αποσύνδεση", "Due to plugin incompatibility, login using browser is unavailable without restart.": - "Λόγω ασυμβατότητας προσθηκών, η σύνδεση μέσω προγράμματος περιήγησης δεν είναι διαθέσιμη χωρίς επανεκκίνηση.", + "Λόγω ασυμβατότητας προσθηκών, η σύνδεση μέσω προγράμματος περιήγησης δεν είναι διαθέσιμη χωρίς επανεκκίνηση.", "(ARL ONLY) Continue": "(ARL ΜΟΝΟ) Συνέχεια", "Log out & Exit": "Αποσύνδεση & Έξοδος", "Pick-a-Path": "Διαλέξτε ένα μονοπάτι", @@ -177,7 +183,7 @@ const language_gr_el = { "Permission denied": "Η άδεια απορρίφθηκε", "Language": "Γλώσσα", "Language changed, please restart Freezer to apply!": - "Η γλώσσα άλλαξε, κάντε επανεκκίνηση του Freezer για εφαρμογή!", + "Η γλώσσα άλλαξε, κάντε επανεκκίνηση του Freezer για εφαρμογή!", "Importing...": "Εισαγωγή...", "Radio": "Ραδιόφωνο", "Flow": "Flow", diff --git a/lib/languages/en_us.dart b/lib/languages/en_us.dart index 2c43c1d..0ee05ff 100644 --- a/lib/languages/en_us.dart +++ b/lib/languages/en_us.dart @@ -160,7 +160,7 @@ const language_en_us = { "Clear": "Clear", "Create folders for artist": "Create folders for artist", "Create folders for albums": "Create folders for albums", - "Separate albums by discs": "Separate albums by discs", + "Separate albums by discs": "Separate albums by disks", "Overwrite already downloaded files": "Overwrite already downloaded files", "Copy ARL": "Copy ARL", "Copy userToken/ARL Cookie for use in other apps.": @@ -182,6 +182,33 @@ const language_en_us = { "Radio": "Radio", "Flow": "Flow", "Track is not available on Deezer!": "Track is not available on Deezer!", - "Failed to download track! Please restart.": "Failed to download track! Please restart." + "Failed to download track! Please restart.": "Failed to download track! Please restart.", + + //0.5.0 Strings: + "Storage permission denied!": "Storage permission denied!", + "Failed": "Failed", + "Queued": "Queued", + "External": "External", + "Restart failed downloads": "Restart failed downloads", + "Clear failed": "Clear failed", + "Download Settings": "Download Settings", + "Create folder for playlist": "Create folder for playlist", + "Download .LRC lyrics": "Download .LRC lyrics", + "Proxy": "Proxy", + "Not set": "Not set", + "Search or paste URL": "Search or paste URL", + "History": "History", + "Download threads": "Download threads", + "Lyrics unavailable, empty or failed to load!": "Lyrics unavailable, empty or failed to load!", + "About": "About", + "Telegram Channel": "Telegram Channel", + "To get latest releases": "To get latest releases", + "Official chat": "Official chat", + "Telegram Group": "Telegram Group", + "Huge thanks to all the contributors! <3": "Huge thanks to all the contributors! <3", + "Edit playlist": "Edit playlist", + "Update": "Update", + "Playlist updated!": "Playlist updated!", + "Downloads added!": "Downloads added!" } }; diff --git a/lib/languages/fr_fr.dart b/lib/languages/fr_fr.dart new file mode 100644 index 0000000..b5eeef3 --- /dev/null +++ b/lib/languages/fr_fr.dart @@ -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." + } +}; diff --git a/lib/languages/he_il.dart b/lib/languages/he_il.dart new file mode 100644 index 0000000..aa3e228 --- /dev/null +++ b/lib/languages/he_il.dart @@ -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.": "הורדת השיר נכשלה! התחל מחדש." + } +}; diff --git a/lib/languages/hr_hr.dart b/lib/languages/hr_hr.dart new file mode 100644 index 0000000..58decf0 --- /dev/null +++ b/lib/languages/hr_hr.dart @@ -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." + } +}; diff --git a/lib/languages/ko_ko.dart b/lib/languages/ko_ko.dart new file mode 100644 index 0000000..4a8f6ed --- /dev/null +++ b/lib/languages/ko_ko.dart @@ -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.": "트랙을 다운로드하지 못했습니다! 다시 시작하십시오.", + } +}; diff --git a/lib/languages/ru_ru.dart b/lib/languages/ru_ru.dart index 8bba117..971b104 100644 --- a/lib/languages/ru_ru.dart +++ b/lib/languages/ru_ru.dart @@ -9,12 +9,11 @@ const language_ru_ru = { "Home": "Главная", "Search": "Поиск", "Library": "Библиотека", - "Offline mode, can't play flow or smart track lists.": - "Автономный режим, нельзя воспроизводить потоки или умные списки треков.", + "Offline mode, can't play flow or smart track lists.": "Офлайн режим, нельзя воспроизводить потоки или умные списки треков.", "Added to library": "Добавить в библиотеку", "Download": "Скачать", - "Disk": "Disk", - "Offline": "Офлайн", + "Disk": "Диск", + "Offline": "Скачанные треки", "Top Tracks": "Лучшие треки", "Show more tracks": "Показать больше треков", "Top": "Top", @@ -24,17 +23,16 @@ const language_ru_ru = { "Default": "По умолчанию", "Reverse": "Обратный", "Alphabetic": "По алфавиту", - "Artist": "Артист", + "Artist": "Исполнитель", "Post processing...": "Постобработка...", "Done": "Готово", "Delete": "Удалить", "Are you sure you want to delete this download?": - "Вы действительно хотите удалить эту загрузку??", + "Вы действительно хотите удалить эту загрузку?", "Cancel": "Отмена", "Downloads": "Загрузки", "Clear queue": "Очистить очередь", - "This won't delete currently downloading item": - "Это не удалит загружаемый в данный момент элемент", + "This won't delete currently downloading item": "Это не удалит загружаемый в данный момент элемент", "Are you sure you want to delete all queued downloads?": "Вы действительно хотите удалить все загрузки в очереди?", "Clear downloads history": "Очистить историю загрузок", @@ -44,20 +42,18 @@ const language_ru_ru = { "Пожалуйста, проверьте ваше соединение и повторите попытку позже...", "Show more": "Показать больше", "Importer": "Импортер", - "Currently supporting only Spotify, with 100 tracks limit": - "В настоящее время поддерживается только Spotify с ограничением 100 треков", + "Currently supporting only Spotify, with 100 tracks limit": "В настоящее время поддерживается только Spotify, с ограничением 100 треков", "Due to API limitations": "Из-за ограничений API", "Enter your playlist link below": "Введите ссылку на свой плейлист ниже", "Error loading URL!": "Ошибка загрузки URL!", "Convert": "Перерабатывать", - "Download only": "Только скачиные", - "Downloading is currently stopped, click here to resume.": - "В настоящее время загрузка остановлена, нажмите здесь, чтобы возобновить.", + "Download only": "Только скачанные", + "Downloading is currently stopped, click here to resume.": "В настоящее время загрузка остановлена, нажмите здесь, чтобы возобновить.", "Tracks": "Треки", "Albums": "Альбомы", "Artists": "Артисты", "Playlists": "Плейлисты", - "Import": "Import", + "Import": "Импорт", "Import playlists from Spotify": "Импортировать плейлисты из Spotify", "Statistics": "Статистика", "Offline tracks": "Автономные треки", @@ -67,10 +63,9 @@ const language_ru_ru = { "Free space": "Свободное место", "Loved tracks": "Любимые треки", "Favorites": "Избранное", - "All offline tracks": "Все оффлайн треки", + "All offline tracks": "Скачанные треки", "Create new playlist": "Создать новый плейлист", - "Cannot create playlists in offline mode": - "Невозможно создавать плейлисты в автономном режиме", + "Cannot create playlists in offline mode": "Невозможно создавать плейлисты в автономном режиме", "Error": "Ошибка", "Error logging in! Please check your token and internet connection and try again.": "Ошибка входа! Проверьте свой токен и подключение к Интернету и повторите попытку.", @@ -127,39 +122,38 @@ const language_ru_ru = { "Show all playlists": "Показать все плейлисты", "Settings": "Настройки", "General": "Общее", - "Appearance": "Внешность", - "Quality": "Качественный", + "Appearance": "Интерфейс", + "Quality": "Качество звука", "Deezer": "Deezer", "Theme": "Тема", - "Currently": "В настоящее время", + "Currently": "Выбрана тема", "Select theme": "Выберите тему", "Light (default)": "Светлая (По умолчанию)", - "Dark": "Темная", + "Dark": "Dark (Темная тема)", "Black (AMOLED)": "Черная (AMOLED)", "Deezer (Dark)": "Deezer (Dark)", "Primary color": "Основной цвет", "Selected color": "Выбранный цвет", - "Use album art primary color": "Использовать основной цвет обложки альбома", + "Use album art primary color": "Использовать цвет обложки", "Warning: might be buggy": "Предупреждение: может быть ошибка", - "Mobile streaming": "Мобильная трансляция", - "Wifi streaming": "Wifi трансляция", + "Mobile streaming": "Мобильная сеть", + "Wifi streaming": "Wifi сеть", "External downloads": "Внешние загрузки", "Content language": "Язык содержания", - "Not app language, used in headers. Now": - "Не язык приложения, используемый в заголовках. Сейчас", + "Not app language, used in headers. Now": "Используемый в заголовках. Сейчас", "Select language": "Выберите язык", "Content country": "Страна содержания", "Country used in headers. Now": "Страна, используемая в заголовках. Сейчас", "Log tracks": "Журнал треков", "Send track listen logs to Deezer, enable it for features like Flow to work properly": "Отправьте журналы прослушивания треков в Deezer, включите его, чтобы такие функции, как Flow, работали правильно", - "Offline mode": "Автономный режим", + "Offline mode": "Офлайн режим", "Will be overwritten on start.": "Будет перезаписан при запуске.", "Error logging in, check your internet connections.": "Ошибка при входе, проверьте свои интернет-соединения.", "Logging in...": "Происходит вход в систему...", - "Download path": "Скачать путь", - "Downloads naming": "Именование загрузок", + "Download path": "Путь сохранения файлов", + "Downloads naming": "Название при скачивании", "Downloaded tracks filename": "Имя файла загруженных треков", "Valid variables are": "Допустимые переменные:", "Reset": "Сброс", @@ -181,7 +175,7 @@ const language_ru_ru = { "Select storage": "Выберите хранилище", "Go up": "Подниматься", "Permission denied": "Доступ запрещен", - "Language": "Язык", + "Language": "Язык приложения", "Language changed, please restart Freezer to apply!": "Язык изменен, перезапустите Freezer, чтобы применить!", "Importing...": "Импорт...", "Radio": "Радио" diff --git a/lib/main.dart b/lib/main.dart index 7cf66cb..dbc7119 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,9 @@ import 'package:audio_service/audio_service.dart'; import 'package:custom_navigator/custom_navigator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:freezer/api/cache.dart'; import 'package:freezer/ui/library.dart'; import 'package:freezer/ui/login_screen.dart'; import 'package:freezer/ui/search.dart'; @@ -28,8 +30,8 @@ void main() async { //Initialize globals settings = await Settings().loadSettings(); - //await imagesDatabase.init(); await downloadManager.init(); + cache = await Cache.load(); runApp(FreezerApp()); } @@ -108,7 +110,7 @@ class _LoginMainWrapperState extends State { //Load token on background deezerAPI.arl = settings.arl; settings.offlineMode = true; - deezerAPI.authorize().then((b) { + deezerAPI.authorize().then((b) async { if (b) setState(() => settings.offlineMode = false); }); } diff --git a/lib/settings.dart b/lib/settings.dart index 36aa717..03d0efb 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; +import 'package:freezer/api/download.dart'; import 'package:freezer/main.dart'; import 'package:freezer/ui/cached_image.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -51,6 +52,12 @@ class Settings { bool albumDiscFolder; @JsonKey(defaultValue: false) bool overwriteDownload; + @JsonKey(defaultValue: 2) + int downloadThreads; + @JsonKey(defaultValue: false) + bool playlistFolder; + @JsonKey(defaultValue: true) + bool downloadLyrics; //Appearance @@ -76,6 +83,8 @@ class Settings { String deezerCountry; @JsonKey(defaultValue: false) bool logListen; + @JsonKey(defaultValue: null) + String proxyAddress; Settings({this.downloadPath, this.arl}); @@ -138,6 +147,14 @@ class Settings { return ThemeData(); } + //JSON to forward into download service + Map getServiceSettings() { + return { + "downloadThreads": downloadThreads, + "overwriteDownload": overwriteDownload, + "downloadLyrics": downloadLyrics + }; + } void updateUseArtColor(bool v) { useArtColor = v; @@ -181,6 +198,7 @@ class Settings { Future save() async { File f = File(await getPath()); await f.writeAsString(jsonEncode(this.toJson())); + downloadManager.updateServiceSettings(); } Future updateAudioServiceQuality() async { diff --git a/lib/settings.g.dart b/lib/settings.g.dart index bc85b93..14f301d 100644 --- a/lib/settings.g.dart +++ b/lib/settings.g.dart @@ -30,13 +30,17 @@ Settings _$SettingsFromJson(Map json) { ..artistFolder = json['artistFolder'] as bool ?? true ..albumDiscFolder = json['albumDiscFolder'] as bool ?? false ..overwriteDownload = json['overwriteDownload'] as bool ?? false + ..downloadThreads = json['downloadThreads'] as int ?? 2 + ..playlistFolder = json['playlistFolder'] as bool ?? false + ..downloadLyrics = json['downloadLyrics'] as bool ?? true ..theme = _$enumDecodeNullable(_$ThemesEnumMap, json['theme']) ?? Themes.Light ..primaryColor = Settings._colorFromJson(json['primaryColor'] as int) ..useArtColor = json['useArtColor'] as bool ?? false ..deezerLanguage = json['deezerLanguage'] as String ?? 'en' ..deezerCountry = json['deezerCountry'] as String ?? 'US' - ..logListen = json['logListen'] as bool ?? false; + ..logListen = json['logListen'] as bool ?? false + ..proxyAddress = json['proxyAddress'] as String; } Map _$SettingsToJson(Settings instance) => { @@ -52,12 +56,16 @@ Map _$SettingsToJson(Settings instance) => { 'artistFolder': instance.artistFolder, 'albumDiscFolder': instance.albumDiscFolder, 'overwriteDownload': instance.overwriteDownload, + 'downloadThreads': instance.downloadThreads, + 'playlistFolder': instance.playlistFolder, + 'downloadLyrics': instance.downloadLyrics, 'theme': _$ThemesEnumMap[instance.theme], 'primaryColor': Settings._colorToJson(instance.primaryColor), 'useArtColor': instance.useArtColor, 'deezerLanguage': instance.deezerLanguage, 'deezerCountry': instance.deezerCountry, 'logListen': instance.logListen, + 'proxyAddress': instance.proxyAddress, }; T _$enumDecode( diff --git a/lib/translations.i18n.dart b/lib/translations.i18n.dart index 476d331..0f7bd26 100644 --- a/lib/translations.i18n.dart +++ b/lib/translations.i18n.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; import 'package:freezer/languages/ar_ar.dart'; import 'package:freezer/languages/de_de.dart'; +import 'package:freezer/languages/el_gr.dart'; import 'package:freezer/languages/en_us.dart'; import 'package:freezer/languages/es_es.dart'; import 'package:freezer/languages/fil_ph.dart'; +import 'package:freezer/languages/fr_fr.dart'; +import 'package:freezer/languages/he_il.dart'; +import 'package:freezer/languages/hr_hr.dart'; import 'package:freezer/languages/it_it.dart'; +import 'package:freezer/languages/ko_ko.dart'; import 'package:freezer/languages/pt_br.dart'; import 'package:freezer/languages/ru_ru.dart'; import 'package:i18n_extension/i18n_extension.dart'; @@ -17,12 +22,19 @@ const supportedLocales = [ const Locale('de', 'DE'), const Locale('ru', 'RU'), const Locale('es', 'ES'), + const Locale('hr', 'HR'), + const Locale('el', 'GR'), + const Locale('ko', 'KO'), + const Locale('fr', 'FR'), + const Locale('he', 'IL'), const Locale('fil', 'PH') ]; extension Localization on String { static var _t = Translations.byLocale("en_US") + - language_en_us + language_ar_ar + language_pt_br + language_it_it + language_de_de + language_ru_ru + language_fil_ph + language_es_es; + language_en_us + language_ar_ar + language_pt_br + language_it_it + language_de_de + language_ru_ru + + language_fil_ph + language_es_es + language_el_gr + language_hr_hr + language_ko_ko + language_fr_fr + + language_he_il; String get i18n => localize(this, _t); } diff --git a/lib/ui/details_screens.dart b/lib/ui/details_screens.dart index 43913bc..55fc86d 100644 --- a/lib/ui/details_screens.dart +++ b/lib/ui/details_screens.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/download.dart'; import 'package:freezer/api/player.dart'; @@ -692,6 +695,22 @@ class _PlaylistDetailsState extends State { } } + //Load cached playlist sorting + void _restoreSort() async { + if (cache.playlistSort == null) { + cache.playlistSort = {}; + await cache.save(); + return; + } + if (cache.playlistSort[playlist.id] != null) { + //Preload tracks + if (playlist.tracks.length < playlist.trackCount) { + playlist = await deezerAPI.fullPlaylist(playlist.id); + } + setState(() => _sort = cache.playlistSort[playlist.id]); + } + } + @override void initState() { playlist = widget.playlist; @@ -717,6 +736,8 @@ class _PlaylistDetailsState extends State { }); } + _restoreSort(); + super.initState(); } @@ -817,7 +838,7 @@ class _PlaylistDetailsState extends State { IconButton( icon: Icon(Icons.favorite, size: 32), onPressed: () async { - await deezerAPI.addFavoriteAlbum(playlist.id); + await deezerAPI.addPlaylist(playlist.id); Fluttertoast.showToast( msg: 'Added to library'.i18n, toastLength: Toast.LENGTH_SHORT, @@ -833,7 +854,17 @@ class _PlaylistDetailsState extends State { ), PopupMenuButton( child: Icon(Icons.sort, size: 32.0), - onSelected: (SortType s) => setState(() => _sort = s), + onSelected: (SortType s) async { + if (playlist.tracks.length < playlist.trackCount) { + //Preload whole playlist + playlist = await deezerAPI.fullPlaylist(playlist.id); + } + setState(() => _sort = s); + + //Save sort type to cache + cache.playlistSort[playlist.id] = s; + cache.save(); + }, itemBuilder: (context) => >[ PopupMenuItem( value: SortType.DEFAULT, diff --git a/lib/ui/downloads_screen.dart b/lib/ui/downloads_screen.dart index b4e81e2..fbc1e01 100644 --- a/lib/ui/downloads_screen.dart +++ b/lib/ui/downloads_screen.dart @@ -1,99 +1,11 @@ +import 'dart:async'; + import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; +import 'package:freezer/api/download.dart'; import 'package:freezer/translations.i18n.dart'; import 'cached_image.dart'; -import '../api/download.dart'; - - -class DownloadTile extends StatelessWidget { - - final Download download; - Function onDelete; - DownloadTile(this.download, {this.onDelete}); - - String get subtitle { - switch (download.state) { - case DownloadState.NONE: return ''; - case DownloadState.DOWNLOADING: - return '${filesize(download.received)} / ${filesize(download.total)}'; - case DownloadState.POST: - return 'Post processing...'.i18n; - case DownloadState.DONE: - return 'Done'.i18n; //Shouldn't be visible - case DownloadState.DEEZER_ERROR: - return 'Track is not available on Deezer!'.i18n; - case DownloadState.ERROR: - return 'Failed to download track! Please restart.'.i18n; - } - return ''; - } - - Widget get progressBar { - switch (download.state) { - case DownloadState.DOWNLOADING: - return LinearProgressIndicator(value: download.received / download.total); - case DownloadState.POST: - return LinearProgressIndicator(); - default: - return Container(height: 0, width: 0,); - } - } - - Widget get trailing { - if (download.private) { - return Icon(Icons.offline_pin); - } - return Icon(Icons.sd_card); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text(download.track.title), - subtitle: Text(subtitle), - leading: CachedImage( - url: download.track.albumArt.thumb, - width: 48.0, - ), - trailing: trailing, - onTap: () { - //Delete if none - if (download.state == DownloadState.NONE) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text('Delete'.i18n), - content: Text('Are you sure you want to delete this download?'.i18n), - actions: [ - FlatButton( - child: Text('Cancel'.i18n), - onPressed: () => Navigator.of(context).pop(), - ), - FlatButton( - child: Text('Delete'.i18n), - onPressed: () { - downloadManager.removeDownload(download); - if (this.onDelete != null) this.onDelete(); - Navigator.of(context).pop(); - }, - ) - ], - ); - } - ); - } - }, - ), - progressBar - ], - ); - } -} class DownloadsScreen extends StatefulWidget { @override @@ -101,6 +13,55 @@ class DownloadsScreen extends StatefulWidget { } class _DownloadsScreenState extends State { + + List downloads = []; + StreamSubscription _stateSubscription; + + //Sublists + List get downloading => downloads.where((d) => d.state == DownloadState.DOWNLOADING || d.state == DownloadState.POST).toList(); + List get queued => downloads.where((d) => d.state == DownloadState.NONE).toList(); + List get failed => downloads.where((d) => d.state == DownloadState.ERROR || d.state == DownloadState.DEEZER_ERROR).toList(); + List get finished => downloads.where((d) => d.state == DownloadState.DONE).toList(); + + Future _load() async { + //Load downloads + List _d = await downloadManager.getDownloads(); + setState(() { + downloads = _d; + }); + } + + @override + void initState() { + _load(); + + //Subscribe to state update + _stateSubscription = downloadManager.serviceEvents.stream.listen((e) { + //State change = update + if (e['action'] == 'onStateChange') { + setState(() => downloadManager.running = downloadManager.running); + } + //Progress change + if (e['action'] == 'onProgress') { + setState(() { + for (Map su in e['data']) { + downloads.firstWhere((d) => d.id == su['id'], orElse: () => Download()).updateFromJson(su); + } + }); + } + }); + + super.initState(); + } + + @override + void dispose() { + if (_stateSubscription != null) + _stateSubscription.cancel(); + _stateSubscription = null; + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -108,100 +69,216 @@ class _DownloadsScreenState extends State { title: Text('Downloads'.i18n), actions: [ IconButton( - icon: Icon(downloadManager.stopped ? Icons.play_arrow : Icons.stop), + icon: + Icon(downloadManager.running ? Icons.stop : Icons.play_arrow), onPressed: () { setState(() { - if (downloadManager.stopped) downloadManager.start(); - else downloadManager.stop(); + if (downloadManager.running) + downloadManager.stop(); + else + downloadManager.start(); }); }, ) ], ), body: ListView( - children: [ - StreamBuilder( - stream: Stream.periodic(Duration(milliseconds: 500)).asBroadcastStream(), //Periodic to get current download progress - builder: (BuildContext context, AsyncSnapshot snapshot) { + children: [ + //Now downloading + Container(height: 2.0), + Column(children: List.generate(downloading.length, (int i) => DownloadTile( + downloading[i], + updateCallback: () => _load(), + ))), + Container(height: 8.0), - if (downloadManager.queue.length == 0) - return Container(width: 0, height: 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(); + }, + ), - return Column( - children: [ - ...List.generate(downloadManager.queue.length, (i) { - return DownloadTile(downloadManager.queue[i], onDelete: () => setState(() => {})); - }), - if (downloadManager.queue.length > 1 || (downloadManager.stopped && downloadManager.queue.length > 0)) - ListTile( - title: Text('Clear queue'.i18n), - subtitle: Text("This won't delete currently downloading item".i18n), - leading: Icon(Icons.delete), - onTap: () async { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text('Delete'.i18n), - content: Text('Are you sure you want to delete all queued downloads?'.i18n), - actions: [ - FlatButton( - child: Text('Cancel'.i18n), - onPressed: () => Navigator.of(context).pop(), - ), - FlatButton( - child: Text('Delete'.i18n), - onPressed: () async { - await downloadManager.clearQueue(); - Navigator.of(context).pop(); - }, - ) - ], - ); - } - ); - }, - ) - ] - ); - }, - ), - FutureBuilder( - future: downloadManager.getFinishedDownloads(), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data.length == 0) return Container(height: 0, width: 0,); + //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(); + }, + ), - return Column( - children: [ - 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(() {}); - }, - ), - ], - ); - }, - ) ], ) ); } } +class DownloadTile extends StatelessWidget { + final Download download; + final Function updateCallback; + DownloadTile(this.download, {this.updateCallback}); + + String subtitle() { + String out = ''; + //Download type + if (download.private) out += 'Offline'.i18n; + else out += 'External'.i18n; + out += ' | '; + //Quality + if (download.quality == 9) out += 'FLAC'; + if (download.quality == 3) out += 'MP3 320kbps'; + if (download.quality == 1) out += 'MP3 128kbps'; + + return out; + } + + Future onClick(BuildContext context) async { + if (download.state != DownloadState.DOWNLOADING && download.state != DownloadState.POST) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Delete'.i18n), + content: Text('Are you sure you want to delete this download?'.i18n), + actions: [ + FlatButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + FlatButton( + child: Text('Delete'.i18n), + onPressed: () async { + await downloadManager.removeDownload(download.id); + if (updateCallback != null) updateCallback(); + Navigator.of(context).pop(); + }, + ) + ], + ); + } + ); + } + } + + //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(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Text(download.title), + leading: CachedImage(url: download.image), + subtitle: Text(subtitle()), + trailing: trailing(), + onTap: () => onClick(context), + ), + if (download.state == DownloadState.DOWNLOADING) + LinearProgressIndicator(value: download.progress), + if (download.state == DownloadState.POST) + LinearProgressIndicator(), + ], + ); + } +} \ No newline at end of file diff --git a/lib/ui/library.dart b/lib/ui/library.dart index 25c1e7e..5eefa40 100644 --- a/lib/ui/library.dart +++ b/lib/ui/library.dart @@ -1,6 +1,7 @@ import 'package:connectivity/connectivity.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/player.dart'; @@ -57,7 +58,7 @@ class LibraryScreen extends StatelessWidget { body: ListView( children: [ Container(height: 4.0,), - if (downloadManager.stopped && downloadManager.queue.length > 0) + if (!downloadManager.running && downloadManager.queueSize > 0) ListTile( title: Text('Downloads'.i18n), leading: Icon(Icons.file_download), @@ -70,7 +71,7 @@ class LibraryScreen extends StatelessWidget { }, ), //Dirty if to not use columns - if (downloadManager.stopped && downloadManager.queue.length > 0) + if (!downloadManager.running && downloadManager.queueSize > 0) Divider(), ListTile( @@ -109,6 +110,15 @@ class LibraryScreen extends StatelessWidget { ); }, ), + ListTile( + title: Text('History'.i18n), + leading: Icon(Icons.history), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => HistoryScreen()) + ); + }, + ), Divider(), ListTile( title: Text('Import'.i18n), @@ -196,14 +206,49 @@ class _LibraryTracksState extends State { ScrollController _scrollController = ScrollController(); List tracks = []; List allTracks = []; + int trackCount; Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId); Future _load() async { + //Already loaded + if (trackCount != null && tracks.length >= trackCount) { + //Update tracks cache if fully loaded + if (cache.libraryTracks == null || cache.libraryTracks.length != trackCount) { + setState(() { + cache.libraryTracks = tracks.map((t) => t.id).toList(); + }); + await cache.save(); + } + return; + } + ConnectivityResult connectivity = await Connectivity().checkConnectivity(); if (connectivity != ConnectivityResult.none) { setState(() => _loading = true); int pos = tracks.length; + + if (trackCount == null || tracks.length == 0) { + //Load tracks as a playlist + Playlist favPlaylist; + try { + favPlaylist = await deezerAPI.playlist(deezerAPI.favoritesPlaylistId); + } catch (e) {} + //Error loading + if (favPlaylist == null) { + setState(() => _loading = false); + return; + } + //Update + setState(() { + trackCount = favPlaylist.trackCount; + tracks = favPlaylist.tracks; + _makeFavorite(); + _loading = false; + }); + return; + } + //Load another page of tracks from deezer List _t; try { @@ -216,6 +261,7 @@ class _LibraryTracksState extends State { } setState(() { tracks.addAll(_t); + _makeFavorite(); _loading = false; }); @@ -236,6 +282,12 @@ class _LibraryTracksState extends State { }); } + //Update tracks with favorite true + void _makeFavorite() { + for (int i=0; i { return Scaffold( appBar: AppBar(title: Text('Tracks'.i18n),), body: ListView( + controller: _scrollController, children: [ Card( child: Column( @@ -554,7 +607,7 @@ class _LibraryPlaylistsState extends State { ListTile( title: Text('Create new playlist'.i18n), leading: Icon(Icons.playlist_add), - onTap: () { + onTap: () async { if (settings.offlineMode) { Fluttertoast.showToast( msg: 'Cannot create playlists in offline mode'.i18n, @@ -563,7 +616,8 @@ class _LibraryPlaylistsState extends State { return; } MenuSheet m = MenuSheet(context); - m.createPlaylist(); + await m.createPlaylist(); + await _load(); }, ), Divider(), @@ -586,6 +640,7 @@ class _LibraryPlaylistsState extends State { }, onHold: () { MenuSheet m = MenuSheet(context); + favoritesPlaylist.library = true; m.defaultPlaylistMenu(favoritesPlaylist); }, ), @@ -600,9 +655,10 @@ class _LibraryPlaylistsState extends State { )), onHold: () { MenuSheet m = MenuSheet(context); - m.defaultPlaylistMenu(p, onRemove: () { - setState(() => _playlists.remove(p)); - }); + m.defaultPlaylistMenu( + p, + onRemove: () {setState(() => _playlists.remove(p));}, + onUpdate: () {_load();}); }, ); }), @@ -653,3 +709,49 @@ class _LibraryPlaylistsState extends State { ); } } + +class HistoryScreen extends StatefulWidget { + @override + _HistoryScreenState createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + @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); + }, + ); + }, + ), + ); + } +} + diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart index 1d24ec5..1cea990 100644 --- a/lib/ui/login_screen.dart +++ b/lib/ui/login_screen.dart @@ -21,6 +21,7 @@ class LoginWidget extends StatefulWidget { class _LoginWidgetState extends State { String _arl; + String _error; //Initialize deezer etc Future _init() async { @@ -62,7 +63,14 @@ class _LoginWidgetState extends State { builder: (context) { return AlertDialog( title: Text('Error'.i18n), - content: Text('Error logging in! Please check your token and internet connection and try again.'.i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Error logging in! Please check your token and internet connection and try again.'.i18n), + if (_error != null) + Text('\n\n$_error') + ], + ), actions: [ FlatButton( child: Text('Dismiss'.i18n), @@ -82,13 +90,15 @@ class _LoginWidgetState extends State { //Try logging in try { deezerAPI.arl = settings.arl; - bool resp = await deezerAPI.authorize(); + bool resp = await deezerAPI.rawAuthorize(onError: (e) => _error = e.toString()); if (resp == false) { //false, not null setState(() => settings.arl = null); errorDialog(); } //On error show dialog and reset to null } catch (e) { + _error = e; + print('Login error: ' + e); setState(() => settings.arl = null); errorDialog(); } diff --git a/lib/ui/menu.dart b/lib/ui/menu.dart index 56fec15..d48a827 100644 --- a/lib/ui/menu.dart +++ b/lib/ui/menu.dart @@ -1,7 +1,10 @@ +import 'dart:ffi'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:audio_service/audio_service.dart'; import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/download.dart'; import 'package:freezer/ui/details_screens.dart'; @@ -123,7 +126,7 @@ class MenuSheet { showWithTrack(track, [ addToQueueNext(track), addToQueue(track), - (track.favorite??false)?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track), + (cache.checkTrackFavorite(track))?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track), addToPlaylist(track), downloadTrack(track), showAlbum(track.album), @@ -169,6 +172,11 @@ class MenuSheet { gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT ); + //Add to cache + if (cache.libraryTracks == null) + cache.libraryTracks = []; + cache.libraryTracks.add(t.id); + _close(); } ); @@ -179,6 +187,7 @@ class MenuSheet { onTap: () async { await downloadManager.addOfflineTrack(t, private: false); _close(); + showDownloadStartedToast(); }, ); @@ -186,76 +195,24 @@ class MenuSheet { title: Text('Add to playlist'.i18n), leading: Icon(Icons.playlist_add), onTap: () async { - - Playlist p; - //Show dialog to pick playlist await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text('Select playlist'.i18n), - content: FutureBuilder( - future: deezerAPI.getPlaylists(), - builder: (context, snapshot) { - - if (snapshot.hasError) SizedBox( - height: 100, - child: ErrorScreen(), - ); - if (snapshot.connectionState != ConnectionState.done) return SizedBox( - height: 100, - child: Center(child: CircularProgressIndicator(),), - ); - - List 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],) - ); - }, - ) - ] - ), - ); - }, - ), + context: context, + builder: (context) { + return SelectPlaylistDialog(track: t, callback: (Playlist p) async { + await deezerAPI.addToPlaylist(t.id, p.id); + //Update the playlist if offline + if (await downloadManager.checkOffline(playlist: p)) { + downloadManager.addOfflinePlaylist(p); + } + Fluttertoast.showToast( + msg: "Track added to".i18n + " ${p.title}", + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, ); - } - ); - //Add to playlist, show toast - if (p != null) { - await deezerAPI.addToPlaylist(t.id, p.id); - //Update the playlist if offline - if (await downloadManager.checkOffline(playlist: p)) { - downloadManager.addOfflinePlaylist(p); + }); } - Fluttertoast.showToast( - msg: "Track added to".i18n + " ${p.title}", - toastLength: Toast.LENGTH_SHORT, - gravity: ToastGravity.BOTTOM, - ); - } - + ); _close(); }, ); @@ -284,12 +241,16 @@ class MenuSheet { if (await downloadManager.checkOffline(playlist: p)) { await downloadManager.addOfflinePlaylist(p); } + //Remove from cache + if (cache.libraryTracks != null) + cache.libraryTracks.removeWhere((i) => i == t.id); Fluttertoast.showToast( msg: 'Track removed from library'.i18n, toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.BOTTOM ); - onUpdate(); + if (onUpdate != null) + onUpdate(); _close(); }, ); @@ -348,8 +309,9 @@ class MenuSheet { title: Text('Download'.i18n), leading: Icon(Icons.file_download), onTap: () async { - await downloadManager.addOfflineAlbum(a, private: false); _close(); + await downloadManager.addOfflineAlbum(a, private: false); + showDownloadStartedToast(); } ); @@ -360,6 +322,7 @@ class MenuSheet { await deezerAPI.addFavoriteAlbum(a.id); await downloadManager.addOfflineAlbum(a, private: true); _close(); + showDownloadStartedToast(); }, ); @@ -441,11 +404,13 @@ class MenuSheet { // PLAYLIST //=================== - void defaultPlaylistMenu(Playlist playlist, {List options = const [], Function onRemove}) { + void defaultPlaylistMenu(Playlist playlist, {List options = const [], Function onRemove, Function onUpdate}) { show([ playlist.library?removePlaylistLibrary(playlist, onRemove: onRemove):addPlaylistLibrary(playlist), addPlaylistOffline(playlist), downloadPlaylist(playlist), + if (playlist.user.id == deezerAPI.userId) + editPlaylist(playlist, onUpdate: onUpdate), ...options ]); } @@ -492,6 +457,7 @@ class MenuSheet { await deezerAPI.addPlaylist(p.id); downloadManager.addOfflinePlaylist(p, private: true); _close(); + showDownloadStartedToast(); }, ); @@ -501,6 +467,21 @@ class MenuSheet { onTap: () async { downloadManager.addOfflinePlaylist(p, private: false); _close(); + showDownloadStartedToast(); + }, + ); + + Widget editPlaylist(Playlist p, {Function onUpdate}) => ListTile( + title: Text('Edit playlist'.i18n), + leading: Icon(Icons.edit), + onTap: () async { + await showDialog( + context: context, + builder: (context) => CreatePlaylistDialog(playlist: p) + ); + _close(); + if (onUpdate != null) + onUpdate(); }, ); @@ -509,9 +490,17 @@ class MenuSheet { // OTHER //=================== + showDownloadStartedToast() { + Fluttertoast.showToast( + msg: 'Downloads added!'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT + ); + } + //Create playlist - void createPlaylist() { - showDialog( + Future createPlaylist() async { + await showDialog( context: context, builder: (BuildContext context) { return CreatePlaylistDialog(); @@ -523,11 +512,90 @@ class MenuSheet { void _close() => Navigator.of(context).pop(); } +class SelectPlaylistDialog extends StatefulWidget { + + final Track track; + final Function callback; + SelectPlaylistDialog({this.track, this.callback, Key key}): super(key: key); + + @override + _SelectPlaylistDialogState createState() => _SelectPlaylistDialogState(); +} + +class _SelectPlaylistDialogState extends State { + + 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 playlists = snapshot.data; + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...List.generate(playlists.length, (i) => ListTile( + title: Text(playlists[i].title), + leading: CachedImage( + url: playlists[i].image.thumb, + ), + onTap: () { + if (widget.callback != null) { + widget.callback(playlists[i]); + } + Navigator.of(context).pop(); + }, + )), + ListTile( + title: Text('Create new playlist'.i18n), + leading: Icon(Icons.add), + onTap: () async { + setState(() { + createNew = true; + }); + }, + ) + ] + ), + ); + }, + ), + ); + } +} + + class CreatePlaylistDialog extends StatefulWidget { final List tracks; - CreatePlaylistDialog({this.tracks, Key key}): super(key: key); + //If playlist not null, update + final Playlist playlist; + CreatePlaylistDialog({this.tracks, this.playlist, Key key}): super(key: key); @override _CreatePlaylistDialogState createState() => _CreatePlaylistDialogState(); @@ -538,11 +606,28 @@ class _CreatePlaylistDialogState extends State { int _playlistType = 1; String _title = ''; String _description = ''; + TextEditingController _titleController; + TextEditingController _descController; + + //Create or edit mode + bool get edit => widget.playlist != null; + + @override + void initState() { + + //Edit playlist mode + if (edit) { + _titleController = TextEditingController(text: widget.playlist.title); + _descController = TextEditingController(text: widget.playlist.description); + } + + super.initState(); + } @override Widget build(BuildContext context) { return AlertDialog( - title: Text('Create playlist'.i18n), + title: Text(edit ? 'Edit playlist'.i18n : 'Create playlist'.i18n), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -550,10 +635,12 @@ class _CreatePlaylistDialogState extends State { decoration: InputDecoration( labelText: 'Title'.i18n ), + controller: _titleController ?? TextEditingController(), onChanged: (String s) => _title = s, ), TextField( onChanged: (String s) => _description = s, + controller: _descController ?? TextEditingController(), decoration: InputDecoration( labelText: 'Description'.i18n ), @@ -583,22 +670,36 @@ class _CreatePlaylistDialogState extends State { onPressed: () => Navigator.of(context).pop(), ), FlatButton( - child: Text('Create'.i18n), + child: Text(edit ? 'Update'.i18n : 'Create'.i18n), onPressed: () async { - List tracks = []; - if (widget.tracks != null) { - tracks = widget.tracks.map((t) => t.id).toList(); + 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 tracks = []; + if (widget.tracks != null) { + tracks = widget.tracks.map((t) => t.id).toList(); + } + await deezerAPI.createPlaylist( + _title, + status: _playlistType, + description: _description, + trackIds: tracks + ); + Fluttertoast.showToast( + msg: 'Playlist created!'.i18n, + gravity: ToastGravity.BOTTOM + ); } - await deezerAPI.createPlaylist( - _title, - status: _playlistType, - description: _description, - trackIds: tracks - ); - Fluttertoast.showToast( - msg: 'Playlist created!'.i18n, - gravity: ToastGravity.BOTTOM - ); Navigator.of(context).pop(); }, ) diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart index 58a8d23..81c8b93 100644 --- a/lib/ui/player_screen.dart +++ b/lib/ui/player_screen.dart @@ -42,6 +42,7 @@ class _PlayerScreenState extends State { playerHelper.startService(); return Center(child: CircularProgressIndicator(),); } + return OrientationBuilder( builder: (context, orientation) { //Landscape @@ -388,9 +389,19 @@ class _LyricsWidgetState extends State { _l = await deezerAPI.lyrics(_trackId); setState(() => _loading = false); } catch (e) { + print(e); //Error Lyrics - setState(() => _l = Lyrics().error); + setState(() => _l = Lyrics.error()); } + + //Empty lyrics + if (_l.lyrics.length == 0) { + setState(() { + _l = Lyrics.error(); + _loading = false; + }); + } + } else { //Use provided lyrics _l = widget.lyrics; diff --git a/lib/ui/search.dart b/lib/ui/search.dart index 14dc785..825bf2c 100644 --- a/lib/ui/search.dart +++ b/lib/ui/search.dart @@ -11,6 +11,32 @@ import '../api/deezer.dart'; import '../api/definitions.dart'; import 'error.dart'; + +openScreenByURL(BuildContext context, String url) async { + DeezerLinkResponse res = await deezerAPI.parseLink(url); + if (res == null) return; + + switch (res.type) { + case DeezerLinkType.TRACK: + Track t = await deezerAPI.track(res.id); + MenuSheet(context).defaultTrackMenu(t); + break; + case DeezerLinkType.ALBUM: + Album a = await deezerAPI.album(res.id); + Navigator.of(context).push(MaterialPageRoute(builder: (context) => AlbumDetails(a))); + break; + case DeezerLinkType.ARTIST: + Artist a = await deezerAPI.artist(res.id); + Navigator.of(context).push(MaterialPageRoute(builder: (context) => ArtistDetails(a))); + break; + case DeezerLinkType.PLAYLIST: + Playlist p = await deezerAPI.playlist(res.id); + Navigator.of(context).push(MaterialPageRoute(builder: (context) => PlaylistDetails(p))); + break; + } + +} + class SearchScreen extends StatefulWidget { @override _SearchScreenState createState() => _SearchScreenState(); @@ -20,11 +46,23 @@ class _SearchScreenState extends State { String _query; bool _offline = false; + bool _loading = false; TextEditingController _controller = new TextEditingController(); List _suggestions = []; - void _submit(BuildContext context, {String query}) { + void _submit(BuildContext context, {String query}) async { if (query != null) _query = query; + + //URL + if (_query.startsWith('http')) { + setState(() => _loading = true); + try { + await openScreenByURL(context, _query); + } catch (e) {} + setState(() => _loading = false); + return; + } + Navigator.of(context).push( MaterialPageRoute(builder: (context) => SearchResultsScreen(_query, offline: _offline,)) ); @@ -45,7 +83,7 @@ class _SearchScreenState extends State { //Load search suggestions Future> _loadSuggestions() async { - if (_query == null || _query.length < 2) return null; + if (_query == null || _query.length < 2 || _query.startsWith('http')) return null; String q = _query; await Future.delayed(Duration(milliseconds: 300)); if (q != _query) return null; @@ -75,7 +113,7 @@ class _SearchScreenState extends State { _loadSuggestions(); }, decoration: InputDecoration( - labelText: 'Search'.i18n + labelText: 'Search or paste URL'.i18n ), controller: _controller, onSubmitted: (String s) => _submit(context, query: s), @@ -112,6 +150,8 @@ class _SearchScreenState extends State { }, ), ), + if (_loading) + LinearProgressIndicator(), Divider(), ...List.generate((_suggestions??[]).length, (i) => ListTile( title: Text(_suggestions[i]), diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index c27003e..f7c69c4 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -6,9 +6,12 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_material_color_picker/flutter_material_color_picker.dart'; +import 'package:fluttericon/font_awesome5_icons.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/download.dart'; import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/home_screen.dart'; import 'package:i18n_extension/i18n_widget.dart'; import 'package:language_pickers/language_pickers.dart'; import 'package:language_pickers/languages.dart'; @@ -17,6 +20,7 @@ import 'package:path_provider_ex/path_provider_ex.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:clipboard/clipboard.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../settings.dart'; import '../main.dart'; @@ -30,20 +34,8 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { - String _about = ''; - - @override - void initState() { - //Load about text - PackageInfo.fromPlatform().then((PackageInfo info) { - setState(() { - _about = '${info.appName}'; - }); - }); - super.initState(); - } - List> _languages() { + //Missing language defaultLanguagesList.add({ 'name': 'Filipino', 'isoCode': 'fil' @@ -71,6 +63,13 @@ class _SettingsScreenState extends State { builder: (context) => GeneralSettings() )), ), + ListTile( + title: Text('Download Settings'.i18n), + leading: Icon(Icons.cloud_download), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => DownloadsSettings() + )), + ), ListTile( title: Text('Appearance'.i18n), leading: Icon(Icons.color_lens), @@ -132,11 +131,13 @@ class _SettingsScreenState extends State { ); }, ), - Divider(), - Text( - _about, - textAlign: TextAlign.center, - ) + ListTile( + title: Text('About'.i18n), + leading: Icon(Icons.info), + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (context) => CreditsScreen() + )), + ), ], ), ); @@ -149,6 +150,10 @@ class AppearanceSettings extends StatefulWidget { } class _AppearanceSettingsState extends State { + + + ColorSwatch _swatch(int c) => ColorSwatch(c, {500: Color(c)}); + @override Widget build(BuildContext context) { return Scaffold( @@ -224,8 +229,19 @@ class _AppearanceSettingsState extends State { return AlertDialog( title: Text('Primary color'.i18n), content: Container( - height: 200, + height: 240, child: MaterialColorPicker( + colors: [ + ...Colors.primaries, + //Logo colors + _swatch(0xffeca704), + _swatch(0xffbe3266), + _swatch(0xff4b2e7e), + _swatch(0xff384697), + _swatch(0xff0880b5), + _swatch(0xff009a85), + _swatch(0xff2ba766) + ], allowShades: false, selectedColor: settings.primaryColor, onMainColorChange: (ColorSwatch color) { @@ -246,9 +262,12 @@ class _AppearanceSettingsState extends State { ListTile( title: Text('Use album art primary color'.i18n), subtitle: Text('Warning: might be buggy'.i18n), - leading: Switch( - value: settings.useArtColor, - onChanged: (v) => setState(() => settings.updateUseArtColor(v)), + leading: Container( + width: 30.0, + child: Checkbox( + value: settings.useArtColor, + onChanged: (v) => setState(() => settings.updateUseArtColor(v)), + ), ), ) ], @@ -450,13 +469,64 @@ class _DeezerSettingsState extends State { ListTile( title: Text('Log tracks'.i18n), subtitle: Text('Send track listen logs to Deezer, enable it for features like Flow to work properly'.i18n), - leading: Checkbox( - value: settings.logListen, - onChanged: (bool v) { - setState(() => settings.logListen = v); - settings.save(); - }, + leading: Container( + width: 30, + child: Checkbox( + value: settings.logListen, + onChanged: (bool v) { + setState(() => settings.logListen = v); + settings.save(); + }, + ), ), + ), + ListTile( + title: Text('Proxy'.i18n), + leading: Icon(Icons.vpn_key), + subtitle: Text(settings.proxyAddress??'Not set'), + onTap: () { + String _new; + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Proxy'.i18n), + content: TextField( + onChanged: (String v) => _new = v, + decoration: InputDecoration( + hintText: 'IP:PORT' + ), + ), + actions: [ + FlatButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + FlatButton( + child: Text('Reset'.i18n), + onPressed: () async { + setState(() { + settings.proxyAddress = null; + }); + await settings.save(); + Navigator.of(context).pop(); + }, + ), + FlatButton( + child: Text('Save'.i18n), + onPressed: () async { + setState(() { + settings.proxyAddress = _new; + }); + await settings.save(); + Navigator.of(context).pop(); + }, + ) + ], + ); + } + ); + }, ) ], ), @@ -464,6 +534,213 @@ class _DeezerSettingsState extends State { } } +class DownloadsSettings extends StatefulWidget { + @override + _DownloadsSettingsState createState() => _DownloadsSettingsState(); +} + +class _DownloadsSettingsState extends State { + + double _downloadThreads = settings.downloadThreads.toDouble(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Download Settings'.i18n),), + body: ListView( + children: [ + ListTile( + title: Text('Download path'.i18n), + leading: Icon(Icons.folder), + subtitle: Text(settings.downloadPath), + onTap: () async { + //Check permissions + if (!(await Permission.storage.request().isGranted)) return; + //Navigate + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => DirectoryPicker(settings.downloadPath, onSelect: (String p) { + setState(() => settings.downloadPath = p); + },) + )); + }, + ), + ListTile( + title: Text('Downloads naming'.i18n), + subtitle: Text('Currently'.i18n + ': ${settings.downloadFilename}'), + leading: Icon(Icons.text_format), + onTap: () { + showDialog( + context: context, + builder: (context) { + + TextEditingController _controller = TextEditingController(); + String filename = settings.downloadFilename; + _controller.value = _controller.value.copyWith(text: filename); + String _new = _controller.value.text; + + //Dialog with filename format + return AlertDialog( + title: Text('Downloaded tracks filename'.i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _controller, + onChanged: (String s) => _new = s, + ), + Container(height: 8.0), + Text( + 'Valid variables are'.i18n + ': %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%, %feats%, %playlistTrackNumber%, %0playlistTrackNumber%, %year%', + style: TextStyle( + fontSize: 12.0, + ), + ) + ], + ), + actions: [ + FlatButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + FlatButton( + child: Text('Reset'.i18n), + onPressed: () { + _controller.value = _controller.value.copyWith( + text: '%artists% - %title%' + ); + _new = '%artists% - %title%'; + }, + ), + FlatButton( + child: Text('Clear'.i18n), + onPressed: () => _controller.clear(), + ), + FlatButton( + child: Text('Save'.i18n), + onPressed: () async { + setState(() { + settings.downloadFilename = _new; + }); + await settings.save(); + Navigator.of(context).pop(); + }, + ) + ], + ); + } + ); + }, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Download threads'.i18n + ': ${_downloadThreads.round().toString()}', + style: TextStyle( + fontSize: 16.0 + ), + ), + ), + Slider( + min: 1, + max: 6, + divisions: 5, + value: _downloadThreads, + label: _downloadThreads.round().toString(), + onChanged: (double v) => setState(() => _downloadThreads = v), + onChangeEnd: (double val) async { + _downloadThreads = val; + setState(() { + settings.downloadThreads = _downloadThreads.round(); + _downloadThreads = settings.downloadThreads.toDouble(); + }); + await settings.save(); + } + ), + ListTile( + title: Text('Create folders for artist'.i18n), + leading: Container( + width: 30.0, + child: Checkbox( + value: settings.artistFolder, + onChanged: (v) { + setState(() => settings.artistFolder = v); + settings.save(); + }, + ), + ), + ), + ListTile( + title: Text('Create folders for albums'.i18n), + leading: Container( + width: 30.0, + child: Checkbox( + value: settings.albumFolder, + onChanged: (v) { + setState(() => settings.albumFolder = v); + settings.save(); + }, + ), + ), + ), + ListTile( + title: Text('Separate albums by discs'.i18n), + leading: Container( + width: 30.0, + child: Checkbox( + value: settings.albumDiscFolder, + onChanged: (v) { + setState(() => settings.albumDiscFolder = v); + settings.save(); + }, + ), + ), + ), + ListTile( + title: Text('Overwrite already downloaded files'.i18n), + leading: Container( + width: 30.0, + child: Checkbox( + value: settings.overwriteDownload, + onChanged: (v) { + setState(() => settings.overwriteDownload = v); + settings.save(); + }, + ), + ), + ), + 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(); @@ -479,163 +756,44 @@ class _GeneralSettingsState extends State { ListTile( title: Text('Offline mode'.i18n), subtitle: Text('Will be overwritten on start.'.i18n), - leading: Switch( - value: settings.offlineMode, - onChanged: (bool v) { - if (v) { - setState(() => settings.offlineMode = true); - return; - } - 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 + 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: [ + CircularProgressIndicator() + ], + ) ); } - Navigator.of(context).pop(); - }); - return AlertDialog( - title: Text('Logging in...'.i18n), - content: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator() - ], - ) - ); - } - ); - }, - ), - ), - ListTile( - title: Text('Download path'.i18n), - leading: Icon(Icons.folder), - subtitle: Text(settings.downloadPath), - onTap: () async { - //Check permissions - if (!(await Permission.storage.request().isGranted)) return; - //Navigate - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => DirectoryPicker(settings.downloadPath, onSelect: (String p) { - setState(() => settings.downloadPath = p); - },) - )); - }, - ), - ListTile( - title: Text('Downloads naming'.i18n), - subtitle: Text('Currently'.i18n + ': ${settings.downloadFilename}'), - leading: Icon(Icons.text_format), - onTap: () { - showDialog( - context: context, - builder: (context) { - - TextEditingController _controller = TextEditingController(); - String filename = settings.downloadFilename; - _controller.value = _controller.value.copyWith(text: filename); - String _new = _controller.value.text; - - //Dialog with filename format - return AlertDialog( - title: Text('Downloaded tracks filename'.i18n), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _controller, - onChanged: (String s) => _new = s, - ), - Container(height: 8.0), - Text( - 'Valid variables are'.i18n + ': %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%, %feats%', - style: TextStyle( - fontSize: 12.0, - ), - ) - ], - ), - actions: [ - FlatButton( - child: Text('Cancel'.i18n), - onPressed: () => Navigator.of(context).pop(), - ), - FlatButton( - child: Text('Reset'.i18n), - onPressed: () { - _controller.value = _controller.value.copyWith( - text: '%artists% - %title%' - ); - _new = '%artists% - %title%'; - }, - ), - FlatButton( - child: Text('Clear'.i18n), - onPressed: () => _controller.clear(), - ), - FlatButton( - child: Text('Save'.i18n), - onPressed: () async { - setState(() { - settings.downloadFilename = _new; - }); - await settings.save(); - Navigator.of(context).pop(); - }, - ) - ], ); - } - ); - }, - ), - ListTile( - title: Text('Create folders for artist'.i18n), - leading: Switch( - value: settings.artistFolder, - onChanged: (v) { - setState(() => settings.artistFolder = v); - settings.save(); - }, - ), - ), - ListTile( - title: Text('Create folders for albums'.i18n), - leading: Switch( - value: settings.albumFolder, - onChanged: (v) { - setState(() => settings.albumFolder = v); - settings.save(); - }, - ), - ), - ListTile( - title: Text('Separate albums by discs'.i18n), - leading: Switch( - value: settings.albumDiscFolder, - onChanged: (v) { - setState(() => settings.albumDiscFolder = v); - settings.save(); - }, - ), - ), - ListTile( - title: Text('Overwrite already downloaded files'.i18n), - leading: Switch( - value: settings.overwriteDownload, - onChanged: (v) { - setState(() => settings.overwriteDownload = v); - settings.save(); - }, + }, + ), ), ), ListTile( @@ -836,3 +994,110 @@ class _DirectoryPickerState extends State { ); } } + +class CreditsScreen extends StatefulWidget { + @override + _CreditsScreenState createState() => _CreditsScreenState(); +} + +class _CreditsScreenState extends State { + + String _version = ''; + + //Title, Subtitle, URL + static final List> 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> 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 + ), + ), + ) + ], + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 77428ff..50b0fd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "6.0.0" + version: "11.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "0.39.14" + version: "0.40.4" args: dependency: transitive description: @@ -28,7 +28,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.4.2" + version: "2.5.0-nullsafety.1" audio_service: dependency: "direct main" description: @@ -49,14 +49,14 @@ packages: name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0-nullsafety.1" build: dependency: transitive description: name: build url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.5.0" build_config: dependency: transitive description: @@ -77,21 +77,21 @@ packages: name: build_resolvers url: "https://pub.dartlang.org" source: hosted - version: "1.3.11" + version: "1.4.1" build_runner: dependency: "direct dev" description: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "1.10.1" + version: "1.10.3" build_runner_core: dependency: transitive description: name: build_runner_core url: "https://pub.dartlang.org" source: hosted - version: "6.0.1" + version: "6.0.3" built_collection: dependency: transitive description: @@ -119,14 +119,14 @@ packages: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.1.0-nullsafety.3" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.1.3" + version: "1.2.0-nullsafety.1" checked_yaml: dependency: transitive description: @@ -140,7 +140,7 @@ packages: name: cli_util url: "https://pub.dartlang.org" source: hosted - version: "0.1.4" + version: "0.2.0" clipboard: dependency: "direct main" description: @@ -154,7 +154,7 @@ packages: name: clock url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "1.1.0-nullsafety.1" code_builder: dependency: transitive description: @@ -168,14 +168,14 @@ packages: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.14.13" + version: "1.15.0-nullsafety.3" connectivity: dependency: "direct main" description: name: connectivity url: "https://pub.dartlang.org" source: hosted - version: "0.4.9+2" + version: "0.4.9+3" connectivity_for_web: dependency: transitive description: @@ -189,7 +189,7 @@ packages: name: connectivity_macos url: "https://pub.dartlang.org" source: hosted - version: "0.1.0+4" + version: "0.1.0+5" connectivity_platform_interface: dependency: transitive description: @@ -245,7 +245,7 @@ packages: name: dart_style url: "https://pub.dartlang.org" source: hosted - version: "1.3.6" + version: "1.3.7" dio: dependency: "direct main" description: @@ -287,7 +287,14 @@ packages: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0-nullsafety.1" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" file: dependency: transitive description: @@ -348,7 +355,7 @@ packages: name: flutter_local_notifications url: "https://pub.dartlang.org" source: hosted - version: "1.4.4+4" + version: "1.4.4+5" flutter_local_notifications_platform_interface: dependency: transitive description: @@ -385,6 +392,13 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttericon: + dependency: "direct main" + description: + name: fluttericon + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.7" fluttertoast: dependency: "direct main" description: @@ -447,7 +461,7 @@ packages: name: i18n_extension url: "https://pub.dartlang.org" source: hosted - version: "1.4.4" + version: "1.4.5" intl: dependency: "direct main" description: @@ -475,14 +489,14 @@ packages: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.1.0" json_serializable: dependency: "direct dev" description: name: json_serializable url: "https://pub.dartlang.org" source: hosted - version: "3.4.1" + version: "3.5.0" just_audio: dependency: "direct main" description: @@ -517,14 +531,14 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.8" + version: "0.12.10-nullsafety.1" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.1.8" + version: "1.3.0-nullsafety.3" mime: dependency: transitive description: @@ -587,14 +601,14 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.7.0" + version: "1.8.0-nullsafety.1" path_provider: dependency: "direct main" description: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "1.6.14" + version: "1.6.18" path_provider_ex: dependency: "direct main" description: @@ -615,7 +629,7 @@ packages: name: path_provider_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.4+3" + version: "0.0.4+4" path_provider_platform_interface: dependency: transitive description: @@ -623,13 +637,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4+1" pedantic: dependency: transitive description: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.9.0" + version: "1.9.2" permission_handler: dependency: "direct main" description: @@ -657,7 +678,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.0.3" pointycastle: dependency: "direct main" description: @@ -739,14 +760,14 @@ packages: name: source_gen url: "https://pub.dartlang.org" source: hosted - version: "0.9.6" + version: "0.9.7+1" source_span: dependency: transitive description: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.7.0" + version: "1.8.0-nullsafety.2" sprintf: dependency: transitive description: @@ -774,14 +795,14 @@ packages: name: stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.9.5" + version: "1.10.0-nullsafety.1" stream_channel: dependency: transitive description: name: stream_channel url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0-nullsafety.1" stream_transform: dependency: transitive description: @@ -795,7 +816,7 @@ packages: name: string_scanner url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "1.1.0-nullsafety.1" synchronized: dependency: transitive description: @@ -809,14 +830,14 @@ packages: name: term_glyph url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0-nullsafety.1" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.17" + version: "0.2.19-nullsafety.2" timing: dependency: transitive description: @@ -830,7 +851,49 @@ packages: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.0-nullsafety.3" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "5.7.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+8" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.8" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4+1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+1" uuid: dependency: transitive description: @@ -844,7 +907,7 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.0.8" + version: "2.1.0-nullsafety.3" watcher: dependency: transitive description: @@ -859,13 +922,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.3" xdg_directories: dependency: transitive description: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.1.0" + version: "0.1.2" yaml: dependency: transitive description: @@ -874,5 +944,5 @@ packages: source: hosted version: "2.2.1" sdks: - dart: ">=2.9.0 <3.0.0" + dart: ">=2.10.0-110 <2.11.0" flutter: ">=1.20.0 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index d5608bc..22e26d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.4.2+1 +version: 0.5.0+1 environment: sdk: ">=2.8.0 <3.0.0" @@ -61,9 +61,11 @@ dependencies: flutter_screenutil: ^2.3.0 marquee: ^1.5.2 flutter_cache_manager: ^1.4.1 - cached_network_image: ^2.2.0+1 + cached_network_image: ^2.3.2+1 clipboard: ^0.1.2+8 i18n_extension: ^1.4.4 + fluttericon: ^1.0.7 + url_launcher: ^5.7.2 audio_session: ^0.0.7 audio_service: