commit 096bf16a32f911d738a3e90c7326e8eaad5021c9 Author: DJDoubleD <34967020+DJDoubleD@users.noreply.github.com> Date: Sun Jul 21 23:42:21 2024 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..991205d --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +#Key stuff +key.properties +**/*.keystore +**/*.jks +android/key.properties + +android/local.properties + +scrobblenaut/.idea +scrobblenaut/.dart_tool + +.gradle/ +android/.gradle +android/.idea + +.flutter-plugins +.flutter-plugins-dependencies + +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ +android/.idea/ +android/local.properties + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +android/app/.cxx + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +coverage/ +pubspec.lock + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +# Envied +.env +./lib/utils/env.g.dart diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..21ad669 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,18 @@ +[submodule "custom_navigator"] + path = custom_navigator + url = https://github.com/DJDoubleD/custom_navigator.git +[submodule "equalizer_flutter"] + path = equalizer_flutter + url = https://github.com/DJDoubleD/equalizer_flutter.git +[submodule "external_path"] + path = external_path + url = https://github.com/DJDoubleD/external_path.git +[submodule "marquee"] + path = marquee + url = https://github.com/DJDoubleD/marquee.git +[submodule "move_to_background"] + path = move_to_background + url = https://github.com/DJDoubleD/move_to_background.git +[submodule "scrobblenaut"] + path = scrobblenaut + url = https://github.com/DJDoubleD/Scrobblenaut.git diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..790d0ac --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + - platform: android + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + - platform: ios + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + - platform: linux + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + - platform: macos + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + - platform: web + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + - platform: windows + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..133b3f3 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +![ReFreezer](./assets/banner.png?raw=true) + +[![Latest Version](https://img.shields.io/github/v/release/DJDoubleD/ReFreezer?color=blue)](../../releases/latest) +[![Release date](https://img.shields.io/github/release-date/DJDoubleD/ReFreezer)](../../releases/latest) +[![Downloads Latest](https://img.shields.io/github/downloads/DJDoubleD/ReFreezer/latest/total?color=blue&label=downloads%20latest)](../../releases) +[![Downloads Total](https://img.shields.io/github/downloads/DJDoubleD/ReFreezer/total?color=blue&label=downloads%20total)](../../releases) +[![Dart](https://img.shields.io/badge/Dart-0175C2?style=for-the-badge&logo=dart&logoColor=white)](https://dart.dev/) +[![Flutter](https://img.shields.io/badge/Flutter-02569B?style=for-the-badge&logo=flutter&logoColor=white)](https://flutter.dev/) +[![Java](https://img.shields.io/badge/Java-ED8B00?style=for-the-badge&logo=openjdk&logoColor=white)](https://www.java.com/) + +--- + + + +An alternative Deezer music streaming & downloading client, based on Freezer. +The entire codebase has been updated/rewritten to be compatible with the latest version of flutter, the dart SDK & android (current build target is API level 34). + +## Features & changes + +- Restored all features of the old Freezer app, most notably: + - Restored all login options + - Restored Highest quality streaming and download options (premium account required, free accounts limited to MP3 128kbps) +- Support downloading to external storage (sdcard) for android 11 and up +- Restored homescreen and added new Flow & Mood smart playlist options +- Improved/fixed queue screen and queue handling (shuffle & rearranging) +- Updated lyrics screen to also support unsynced lyrics +- Some minor UI changes to better accomadate horizontal/tablet view +- Updated entire codebase to fully support latest flutter & dart SDK versions +- Updated to gradle version 8.7 +- Removed included c libraries (openssl & opencrypto) and replaced them with custom native java implementation +- Replaced the included decryptor-jni c library with a custom native java implementation +- Implemented null-safety +- Removed the need of custom just_audio & audio_service plugin versions & refactored source code to use the latest version of the official plugins +- Multiple other fixes + +## Compile from source + +Install the latest flutter SDK: +(Optional) Generate keys for release build: + +Download source: + +```powershell +git clone https://github.com/DJDoubleD/ReFreezer +git submodule init +git submodule update +``` + +Compile: + +```powershell +flutter pub get +dart run build_runner clean +dart run build_runner build --delete-conflicting-outputs + flutter build apk --split-per-abi --release +``` + +NOTE: You have to use own keys, or build debug using `flutter build apk --debug` + +## Disclaimer & Legal + +**ReFreezer** was not developed for piracy, but educational and private usage. +It may be illegal to use this in your country! +I will not be responsible for how you use **ReFreezer**. + +**ReFreezer** uses both Deezer's public and internal API's, but is not endorsed, certified or otherwise approved in any way by Deezer. + +The Deezer brand and name is the registered trademark of its respective owner. + +**ReFreezer** has no partnership, sponsorship or endorsement with Deezer. + +By using **ReFreezer** you agree to the following: diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..4594b42 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,46 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + constant_identifier_names: false + library_private_types_in_public_api: false + prefer_adjacent_string_concatenation: false + prefer_collection_literals: false + prefer_interpolation_to_compose_strings: false + unnecessary_string_escapes: false + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +analyzer: + exclude: + - "**/*.g.dart" + # workaround for https://github.com/dart-lang/sdk/issues/42910 + - "Scrobblenaut/**" + - "custom_navigator/**" + - "equalizer_flutter/**" + - "external_path/**" + - "move_to_background/**" + - "saf/**" diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..b752fae --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,103 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace 'r.r.refreezer' + compileSdk 34 + //ndkVersion flutter.ndkVersion + ndkVersion "26.1.10909125" + buildFeatures.buildConfig = true + + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + // Sets Java compatibility to Java 8 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + applicationId "r.r.refreezer" + minSdk 21 + targetSdk 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + signingConfigs { + release { + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storePassword keystoreProperties['storePassword'] + } + } + + buildTypes { + release { + signingConfig signingConfigs.release + shrinkResources false + minifyEnabled false + } + debug { + applicationIdSuffix '.debug' + versionNameSuffix ' DEBUG' + } + } + + /*externalNativeBuild { + ndkBuild { + path file('src/main/jni/Android.mk') + } + }*/ + lint { + disable 'InvalidPackage' + } +} + +dependencies { + implementation files('libs/extension-flac.aar') // Required for older Android version + implementation("androidx.activity:activity-ktx:1.9.0") + //implementation group: 'net.jthink', name: 'jaudiotagger', version: '3.0.1' // requires java 8 so no android 6 + implementation group: 'net.jthink', name: 'jaudiotagger', version: '2.2.5' + implementation group: 'org.nanohttpd', name: 'nanohttpd', version: '2.3.1' + implementation 'androidx.documentfile:documentfile:1.0.1' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' +} + +flutter { + source '../..' +} diff --git a/android/app/libs/extension-flac.aar b/android/app/libs/extension-flac.aar new file mode 100644 index 0000000..95cde98 Binary files /dev/null and b/android/app/libs/extension-flac.aar differ diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..30176ce --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..9505b37 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/ic_launcher_new-playstore.png b/android/app/src/main/ic_launcher_new-playstore.png new file mode 100644 index 0000000..d5b806a Binary files /dev/null and b/android/app/src/main/ic_launcher_new-playstore.png differ diff --git a/android/app/src/main/java/r/r/refreezer/Deezer.java b/android/app/src/main/java/r/r/refreezer/Deezer.java new file mode 100644 index 0000000..0b8f49c --- /dev/null +++ b/android/app/src/main/java/r/r/refreezer/Deezer.java @@ -0,0 +1,639 @@ +package r.r.refreezer; + +import android.util.Log; +import android.util.Pair; + +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.flac.FlacTag; +import org.jaudiotagger.tag.id3.ID3v23Tag; +import org.jaudiotagger.tag.id3.valuepair.ImageFormats; +import org.jaudiotagger.tag.images.Artwork; +import org.jaudiotagger.tag.images.ArtworkFactory; +import org.jaudiotagger.tag.reference.PictureTypes; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.RandomAccessFile; +import java.net.URL; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Scanner; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import javax.net.ssl.HttpsURLConnection; + +public class Deezer { + + static String USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36"; + DownloadLog logger; + String token; + String arl; + String sid; + String licenseToken; + String contentLanguage = "en"; + boolean authorized = false; + boolean authorizing = false; + + Deezer() {} + + //Initialize for logging + void init(DownloadLog logger, String arl) { + //Load native + //System.loadLibrary("decryptor-jni"); + + this.logger = logger; + this.arl = arl; + } + + // Method for when using c libraries for decryption + //public native void decryptFile(String trackId, String inputFilename, String outputFilename); + + //Authorize GWLight API + public void authorize() { + if (!authorized || sid == null || token == null) { + authorizing = true; + try { + callGWAPI("deezer.getUserData", "{}"); + authorized = true; + } catch (Exception e) { + logger.warn("Error authorizing to Deezer API! " + e); + } + } + authorizing = false; + } + + //Make POST request + private String POST(String _url, String data, String cookies) { + String result = null; + + try { + URL url = new URL(_url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setConnectTimeout(20000); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + connection.setRequestProperty("User-Agent", USER_AGENT); + connection.setRequestProperty("Accept-Language", contentLanguage + ",*"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Accept", "*/*"); + if (cookies != null) { + connection.setRequestProperty("Cookie", cookies); + } + + //Write body + try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) { + wr.writeBytes(data); + } + + //Get response + try (Scanner scanner = new Scanner(connection.getInputStream())) { + StringBuilder output = new StringBuilder(); + while (scanner.hasNext()) { + output.append(scanner.nextLine()); + } + result = output.toString(); + } + } catch (Exception e) { + e.printStackTrace(); + } + + return result; + } + + public JSONObject callGWAPI(String method, String body) throws Exception { + //Get token + if (token == null) { + token = "null"; + callGWAPI("deezer.getUserData", "{}"); + } + + String data = POST( + "https://www.deezer.com/ajax/gw-light.php?method=" + method + "&input=3&api_version=1.0&api_token=" + token, + body, + "arl=" + arl + "; sid=" + sid + ); + + //Parse JSON + JSONObject out = new JSONObject(data); + + //Save token + if ((token == null || token.equals("null")) && method.equals("deezer.getUserData")) { + token = out.getJSONObject("results").getString("checkForm"); + sid = out.getJSONObject("results").getString("SESSION_ID"); + + // Get User license code + try { + JSONObject userData = out.getJSONObject("results").getJSONObject("USER"); + licenseToken = userData.getJSONObject("OPTIONS").getString("license_token"); + } catch (JSONException e) { + e.printStackTrace(); + logger.warn("Error getting user License Token - FLAC not available! " + e); + } + } + + return out; + } + + + //api.deezer.com/$method/$param + public 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.setRequestProperty("Accept-Language", contentLanguage + ",*"); + connection.setConnectTimeout(20000); + connection.connect(); + + //Get string data + StringBuilder data = new StringBuilder(); + InputStream inputStream = connection.getInputStream(); + try (Scanner scanner = new Scanner(new InputStreamReader(inputStream))) { + while (scanner.hasNext()) { + data.append(scanner.nextLine()); + } + } finally { + connection.disconnect(); + } + + //Parse JSON & return + return new JSONObject(data.toString()); + } + + //Generate track download URL + public String generateTrackUrl(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 = DeezerDecryptor.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 getTrackUrl(String trackId, String trackToken, String md5origin, String mediaVersion, + int quality, int refreshAttempt) { + // Hi-Fi url gen + if (this.licenseToken != null && (quality == 3 || quality == 9)) { + String url = null; + String format = "FLAC"; + + if (quality == 3) format = "MP3_320"; + + try { + // Create track_url payload + String payload = "{\n" + + "\"license_token\": \"" + licenseToken + "\",\n" + + "\"media\": [{ \"type\": \"FULL\", \"formats\": [{ \"cipher\": \"BF_CBC_STRIPE\", \"format\": \"" + format + "\"}]}],\n" + + "\"track_tokens\": [\"" + trackToken + "\"]\n" + + "}"; + String output = POST("https://media.deezer.com/v1/get_url", payload, "arl=" + arl); + + JSONObject result = new JSONObject(output); + + if (result.has("data")){ + for (int i = 0; i < result.getJSONArray("data").length(); i++){ + JSONObject data = result.getJSONArray("data").getJSONObject(i); + if (data.has("errors")){ + JSONArray errors = data.getJSONArray("errors"); + for (int j = 0; j < errors.length(); j++) { + JSONObject error = errors.getJSONObject(j); + if (error.getInt("code") == 2001 && refreshAttempt < 1) { + // Track token is expired, attempt 1 track data refresh + JSONObject privateJson = callGWAPI("song.getListData", "{\"sng_ids\": [" + trackId + "]}"); + JSONObject trackData = privateJson.getJSONObject("results").getJSONArray("data").getJSONObject(0); + trackId = trackData.getString("SNG_ID"); + trackToken = trackData.getString("TRACK_TOKEN"); + md5origin = trackData.getString("MD5_ORIGIN"); + mediaVersion = trackData.getString("MEDIA_VERSION"); + + // Retry getTrackUrl with refreshed track data and increment retry count + return getTrackUrl(trackId, trackToken, md5origin, mediaVersion, quality, refreshAttempt + 1); + } + } + logger.warn("Failed in getting streaming URL: " + data.get("errors")); + } + if (data.has("media") && data.getJSONArray("media").length() > 0){ + url = data.getJSONArray("media").getJSONObject(0).getJSONArray("sources").getJSONObject(0).getString("url"); + break; + } + } + } + } catch (Exception e) { + e.printStackTrace(); + logger.warn("Error getting streaming URL: " + e); + } + return new Pair(url,true); + } + // Legacy url generation, now only for MP3_128 + return new Pair(generateTrackUrl(trackId, md5origin, mediaVersion, quality), true); + } + + 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"))); + // Album might not be available + try { + original = original.replaceAll("%albumArtist%", sanitize(publicAlbum.getJSONObject("artist").getString("name"))); + } catch (Exception e) { + original = original.replaceAll("%albumArtist%", sanitize(publicTrack.getJSONObject("artist").getString("name"))); + } + + //Artists + String artists = ""; + String feats = ""; + for (int i=0; i 0 && !artists.contains(artist) && !feats.contains(artist)) + feats += ", " + artist; + } + 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)); + original = original.replaceAll("%date%", publicTrack.getString("release_date")); + + //Remove leading dots + original = original.replaceAll("/\\.+", "/"); + + if (newQuality == 9) return original + ".flac"; + return original + ".mp3"; + } + + //Deezer patched something so getting metadata of user uploaded MP3s is not working anymore + public static String generateUserUploadedMP3Filename(String original, String title) { + String[] ignored = {"%feats%", "%trackNumber%", "%0trackNumber%", "%year%", "%date%", "%album%", "%artist%", "%artists%", "%albumArtist%"}; + for (String i : ignored) { + original = original.replaceAll(i, ""); + } + + original = original.replace("%title%", sanitize(title)); + return original; + } + + //Tag track with data from API + public void tagTrack(String path, JSONObject publicTrack, JSONObject publicAlbum, String cover, JSONObject lyricsData, JSONObject privateJson, DownloadService.DownloadSettings settings) 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(); + + if (settings.tags.title) tag.setField(FieldKey.TITLE, publicTrack.getString("title")); + if (settings.tags.album) tag.setField(FieldKey.ALBUM, publicTrack.getJSONObject("album").getString("title")); + //Artist + String artists = ""; + for (int i=0; i 0) + if (settings.tags.bpm) tag.setField(FieldKey.BPM, Integer.toString((int)publicTrack.getDouble("bpm"))); + + //Unsynced lyrics + if (lyricsData != null && settings.tags.lyrics) { + try { + String lyrics = lyricsData.getString("LYRICS_TEXT"); + tag.setField(FieldKey.LYRICS, lyrics); + } catch (Exception e) { + Log.w("WARN", "Error adding unsynced lyrics!"); + } + } + + //Genres + String genres = ""; + if (albumAvailable) { + for (int i=0; i 2 && settings.tags.genre) + tag.setField(FieldKey.GENRE, genres.substring(2)); + } + + //Additional tags from private api + if (settings.tags.contributors) { + try { + if (privateJson != null && privateJson.has("SNG_CONTRIBUTORS")) { + JSONObject contrib = privateJson.getJSONObject("SNG_CONTRIBUTORS"); + //Composer + if (contrib.has("composer")) { + JSONArray composers = contrib.getJSONArray("composer"); + String composer = ""; + for (int i = 0; i < composers.length(); i++) + composer += settings.artistSeparator + composers.getString(i); + if (composer.length() > 2) + tag.setField(FieldKey.COMPOSER, composer.substring(settings.artistSeparator.length())); + } + //Engineer + if (contrib.has("engineer")) { + JSONArray engineers = contrib.getJSONArray("engineer"); + String engineer = ""; + for (int i = 0; i < engineers.length(); i++) + engineer += settings.artistSeparator + engineers.getString(i); + if (engineer.length() > 2) + tag.setField(FieldKey.ENGINEER, engineer.substring(settings.artistSeparator.length())); + } + //Mixer + if (contrib.has("mixer")) { + JSONArray mixers = contrib.getJSONArray("mixer"); + String mixer = ""; + for (int i = 0; i < mixers.length(); i++) + mixer += settings.artistSeparator + mixers.getString(i); + if (mixer.length() > 2) + tag.setField(FieldKey.MIXER, mixer.substring(settings.artistSeparator.length())); + } + //Producer + if (contrib.has("producer")) { + JSONArray producers = contrib.getJSONArray("producer"); + String producer = ""; + for (int i = 0; i < producers.length(); i++) + producer += settings.artistSeparator + producers.getString(i); + if (producer.length() > 2) + tag.setField(FieldKey.MIXER, producer.substring(settings.artistSeparator.length())); + } + + //FLAC Only + if (isFlac) { + //Author + if (contrib.has("author")) { + JSONArray authors = contrib.getJSONArray("author"); + String author = ""; + for (int i = 0; i < authors.length(); i++) + author += settings.artistSeparator + authors.getString(i); + if (author.length() > 2) + ((FlacTag) tag).setField("AUTHOR", author.substring(settings.artistSeparator.length())); + } + //Writer + if (contrib.has("writer")) { + JSONArray writers = contrib.getJSONArray("writer"); + String writer = ""; + for (int i = 0; i < writers.length(); i++) + writer += settings.artistSeparator + writers.getString(i); + if (writer.length() > 2) + ((FlacTag) tag).setField("WRITER", writer.substring(settings.artistSeparator.length())); + } + } + } + } catch (Exception e) { + logger.warn("Error writing contributors data: " + e); + } + } + + File coverFile = new File(cover); + boolean addCover = (coverFile.exists() && coverFile.length() > 0); + + if (isFlac) { + //FLAC Specific tags + if (settings.tags.date) ((FlacTag)tag).setField("DATE", publicTrack.getString("release_date")); + //Cover + if (addCover && settings.tags.albumArt) { + try (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", + settings.albumArtResolution, + settings.albumArtResolution, + 24, + 0 + )); + } catch (Exception e) { + logger.warn("Error writing coverFile artwork: " + e); + } + } + } else { + if (addCover && settings.tags.albumArt) { + Artwork art = ArtworkFactory.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 urlGen = deezer.getTrackUrl(trackId, trackToken, md5origin, mediaVersion, quality, 0); + this.encrypted = urlGen.second; + + // initialise as "404 Not Found" + int urlResponseCode = 404; + + if (urlGen.first != null) { + //Create HEAD requests to check if exists + URL url = new URL(urlGen.first); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + connection.setRequestProperty("User-Agent", USER_AGENT); + connection.setRequestProperty("Accept-Language", "*"); + connection.setRequestProperty("Accept", "*/*"); + urlResponseCode = connection.getResponseCode(); + } + //Track not available + if (urlResponseCode > 400) { + logger.warn("Quality fallback, response code: " + urlResponseCode + ", current: " + Integer.toString(quality)); + //-1 if no quality available + if (quality == 1) { + quality = -1; + return null; + } + if (quality == 3) quality = 1; + if (quality == 9) quality = 3; + return qualityFallback(deezer); + } + return urlGen.first; + } + + } +} diff --git a/android/app/src/main/java/r/r/refreezer/DeezerDecryptor.java b/android/app/src/main/java/r/r/refreezer/DeezerDecryptor.java new file mode 100644 index 0000000..8fc7e64 --- /dev/null +++ b/android/app/src/main/java/r/r/refreezer/DeezerDecryptor.java @@ -0,0 +1,94 @@ +package r.r.refreezer; + +import android.util.Log; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.MessageDigest; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +public class DeezerDecryptor { + + public static void decryptFile(String trackId, String inputFilename, String outputFilename) throws IOException { + try (FileInputStream fis = new FileInputStream(inputFilename); + FileOutputStream fos = new FileOutputStream(outputFilename)) { + byte[] key = getKey(trackId); + byte[] buffer = new byte[2048]; + int bytesRead; + int chunkCounter = 0; + + while ((bytesRead = fis.read(buffer)) != -1) { + // Only every 3rd chunk of exactly 2048 bytes should be decrypted + if (bytesRead == 2048 && (chunkCounter % 3) == 0) { + buffer = decryptChunk(key, buffer); + } + fos.write(buffer, 0, bytesRead); + chunkCounter++; + } + } catch (IOException e) { + throw e; + } + } + + public static String bytesToHex(byte[] bytes) { + final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + + /** + * Generates the Track decryption key based on the provided track ID and a secret. + * @param id Track ID used to generate decryption key + * @return Decryption key for Track + */ + static byte[] getKey(String id) { + final String secret = "g4el58wc0zvf9na1"; + try { + MessageDigest md5 = MessageDigest.getInstance("MD5"); + md5.update(id.getBytes()); + byte[] md5id = md5.digest(); + String idmd5 = bytesToHex(md5id).toLowerCase(); + String key = ""; + 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); + } + return key.getBytes(); + } catch (Exception e) { + Log.e("E", e.toString()); + return new byte[0]; + } + } + + /** + * Decrypts a 2048-byte chunk of data using the Blowfish algorithm in CBC mode with no padding. + * The decryption key and the initial vector (IV) are used to decrypt the data. + * @param key Track key + * @param data 2048-byte chunk of data to decrypt + * @return Decrypted 2048-byte chunk + * + */ + static byte[] decryptChunk(byte[] key, byte[] data) { + try { + 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); + }catch (Exception e) { + Log.e("D", e.toString()); + return new byte[0]; + } + } + +} \ No newline at end of file diff --git a/android/app/src/main/java/r/r/refreezer/Download.java b/android/app/src/main/java/r/r/refreezer/Download.java new file mode 100644 index 0000000..eb5b44b --- /dev/null +++ b/android/app/src/main/java/r/r/refreezer/Download.java @@ -0,0 +1,113 @@ +package r.r.refreezer; + +import android.content.ContentValues; +import android.database.Cursor; +import java.util.HashMap; + + +public class Download { + int id; + String path; + boolean priv; + int quality; + String trackId; + String streamTrackId; + String trackToken; + String md5origin; + String mediaVersion; + DownloadState state; + String title; + String image; + + //Dynamic + long received; + long filesize; + + Download(int id, String path, boolean priv, int quality, DownloadState state, String trackId, String md5origin, String mediaVersion, String title, String image, String trackToken, String streamTrackId) { + this.id = id; + this.path = path; + this.priv = priv; + this.trackId = trackId; + this.md5origin = md5origin; + this.state = state; + this.mediaVersion = mediaVersion; + this.title = title; + this.image = image; + this.quality = quality; + this.trackToken = trackToken; + this.streamTrackId = streamTrackId; + } + + enum DownloadState { + NONE(0), + DOWNLOADING (1), + POST(2), + DONE(3), + DEEZER_ERROR(4), + ERROR(5); + + private final int value; + private DownloadState(int value) { + this.value = value; + } + public int getValue() { + return value; + } + } + + //Negative TrackIDs = User uploaded MP3s. + public boolean isUserUploaded() { + return trackId.startsWith("-"); + } + + //Get download from SQLite cursor, HAS TO ALIGN (see DownloadsDatabase onCreate) + static Download fromSQL(Cursor cursor) { + return new Download(cursor.getInt(0), + cursor.getString(1), + cursor.getInt(2) == 1, + cursor.getInt(3), + DownloadState.values()[cursor.getInt(4)], + cursor.getString(5), + cursor.getString(6), + cursor.getString(7), + cursor.getString(8), + cursor.getString(9), + cursor.getString(10), + cursor.getString(11) + ); + } + + //Convert object from method call to SQL ContentValues + static ContentValues flutterToSQL(HashMap data) { + ContentValues values = new ContentValues(); + values.put("path", (String)data.get("path")); + values.put("private", ((boolean)data.get("private")) ? 1 : 0); + values.put("state", 0); + values.put("trackId", (String)data.get("trackId")); + values.put("md5origin", (String)data.get("md5origin")); + values.put("mediaVersion", (String)data.get("mediaVersion")); + values.put("title", (String)data.get("title")); + values.put("image", (String)data.get("image")); + values.put("quality", (int)data.get("quality")); + values.put("trackToken", (String)data.get("trackToken")); + values.put("streamTrackId", (String)data.get("streamTrackId")); + + return values; + } + + //Used to send data to Flutter + HashMap toHashMap() { + HashMap map = new HashMap(); + map.put("id", id); + map.put("path", path); + map.put("private", priv); + map.put("quality", quality); + map.put("trackId", trackId); + map.put("state", state.getValue()); + map.put("title", title); + map.put("image", image); + //Only useful data, some are passed in updates + return map; + } +} + diff --git a/android/app/src/main/java/r/r/refreezer/DownloadLog.java b/android/app/src/main/java/r/r/refreezer/DownloadLog.java new file mode 100644 index 0000000..9fb454d --- /dev/null +++ b/android/app/src/main/java/r/r/refreezer/DownloadLog.java @@ -0,0 +1,101 @@ +package r.r.refreezer; + +import android.content.Context; +import android.util.Log; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Locale; + +public class DownloadLog { + + BufferedWriter writer; + + //Open/Create file + void open(Context context) { + File file = new File(context.getExternalFilesDir(""), "download.log"); + try { + if (!file.exists()) { + file.createNewFile(); + } + writer = new BufferedWriter(new FileWriter(file, true)); + } catch (Exception ignored) { + Log.e("DOWN", "Error opening download log!"); + } + } + + //Close log + void close() { + try { + writer.close(); + } catch (Exception ignored) { + Log.w("DOWN", "Error closing download log!"); + } + } + + String time() { + SimpleDateFormat format = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss", Locale.US); + return format.format(Calendar.getInstance().getTime()); + } + + //Write error to log + void error(String info) { + if (writer == null) return; + String data = "E:" + time() + ": " + info; + try { + writer.write(data); + writer.newLine(); + writer.flush(); + } catch (Exception ignored) { + Log.w("DOWN", "Error writing into log."); + } + Log.e("DOWN", data); + } + + //Write error to log with download info + void error(String info, Download download) { + if (writer == null) return; + String data = "E:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info; + try { + writer.write(data); + writer.newLine(); + writer.flush(); + } catch (Exception ignored) { + Log.w("DOWN", "Error writing into log."); + } + Log.e("DOWN", data); + } + + //Write warning to log + void warn(String info) { + if (writer == null) return; + String data = "W:" + time() + ": " + info; + try { + writer.write(data); + writer.newLine(); + writer.flush(); + } catch (Exception ignored) { + Log.w("DOWN", "Error writing into log."); + } + Log.w("DOWN", data); + } + + //Write warning to log with download info + void warn(String info, Download download) { + if (writer == null) return; + String data = "W:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info; + try { + writer.write(data); + writer.newLine(); + writer.flush(); + } catch (Exception ignored) { + Log.w("DOWN", "Error writing into log."); + } + Log.w("DOWN", data); + } + +} diff --git a/android/app/src/main/java/r/r/refreezer/DownloadService.java b/android/app/src/main/java/r/r/refreezer/DownloadService.java new file mode 100644 index 0000000..e8dfd34 --- /dev/null +++ b/android/app/src/main/java/r/r/refreezer/DownloadService.java @@ -0,0 +1,950 @@ +package r.r.refreezer; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import android.util.Log; + +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.channels.FileChannel; +import java.text.DecimalFormat; +import java.util.ArrayList; + +import javax.net.ssl.HttpsURLConnection; + +public class DownloadService extends Service { + + //Message commands + static final int SERVICE_LOAD_DOWNLOADS = 1; + static final int SERVICE_START_DOWNLOAD = 2; + static final int SERVICE_ON_PROGRESS = 3; + static final int SERVICE_SETTINGS_UPDATE = 4; + static final int SERVICE_STOP_DOWNLOADS = 5; + static final int SERVICE_ON_STATE_CHANGE = 6; + static final int SERVICE_REMOVE_DOWNLOAD = 7; + static final int SERVICE_RETRY_DOWNLOADS = 8; + static final int SERVICE_REMOVE_DOWNLOADS = 9; + + static final String NOTIFICATION_CHANNEL_ID = "refreezerdownloads"; + static final int NOTIFICATION_ID_START = 6969; + + boolean running = false; + DownloadSettings settings; + Context context; + SQLiteDatabase db; + Deezer deezer = new Deezer(); + + Messenger serviceMessenger; + Messenger activityMessenger; + NotificationManagerCompat notificationManager; + + ArrayList downloads = new ArrayList<>(); + ArrayList threads = new ArrayList<>(); + ArrayList updateRequests = new ArrayList<>(); + boolean updating = false; + Handler progressUpdateHandler = new Handler(); + DownloadLog logger = new DownloadLog(); + + public DownloadService() { + } + + @Override + public void onCreate() { + super.onCreate(); + + //Setup notifications + context = this; + notificationManager = NotificationManagerCompat.from(context); + createNotificationChannel(); + createProgressUpdateHandler(); + + //Setup logger, deezer api + logger.open(context); + deezer.init(logger, ""); + + //Get DB + DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext()); + db = dbHelper.getWritableDatabase(); + } + + @Override + public void onDestroy() { + //Cancel notifications + notificationManager.cancelAll(); + //Logger + logger.close(); + 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); + //Prevent battery savers I guess + return START_STICKY; + } + + //Android O+ Notifications + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_MIN); + NotificationManager nManager = getSystemService(NotificationManager.class); + nManager.createNotificationChannel(channel); + } + } + + //Update download tasks + private void updateQueue() { + db.beginTransaction(); + + //Clear downloaded tracks + for (int i = threads.size() - 1; i >= 0; i--) { + Download.DownloadState state = threads.get(i).download.state; + if (state == Download.DownloadState.NONE || state == Download.DownloadState.DONE || state == Download.DownloadState.ERROR || state == Download.DownloadState.DEEZER_ERROR) { + Download d = threads.get(i).download; + //Update in queue + for (int j = 0; j < downloads.size(); j++) { + if (downloads.get(j).id == d.id) { + downloads.set(j, d); + } + } + updateProgress(); + //Save to DB + ContentValues row = new ContentValues(); + row.put("state", state.getValue()); + row.put("quality", d.quality); + db.update("Downloads", row, "id == ?", new String[]{Integer.toString(d.id)}); + + //Update library + if (state == Download.DownloadState.DONE && !d.priv) { + Uri uri = Uri.fromFile(new File(threads.get(i).outFile.getPath())); + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)); + } + + //Remove thread + threads.remove(i); + } + } + + db.setTransactionSuccessful(); + db.endTransaction(); + + //Create new download tasks + if (running) { + int nThreads = settings.downloadThreads - threads.size(); + for (int i = 0; i < nThreads; i++) { + for (int j = 0; j < downloads.size(); j++) { + if (downloads.get(j).state == Download.DownloadState.NONE) { + //Update download + Download d = downloads.get(j); + d.state = Download.DownloadState.DOWNLOADING; + downloads.set(j, d); + + //Create thread + DownloadThread thread = new DownloadThread(d); + thread.start(); + threads.add(thread); + break; + } + } + } + //Check if last download + if (threads.isEmpty()) { + running = false; + } + } + //Send updates to UI + updateProgress(); + updateState(); + } + + //Send state change to UI + private void updateState() { + Bundle b = new Bundle(); + b.putBoolean("running", running); + //Get count of not downloaded tracks + int queueSize = 0; + for (int i = 0; i < downloads.size(); i++) { + if (downloads.get(i).state == Download.DownloadState.NONE) + queueSize++; + } + b.putInt("queueSize", queueSize); + sendMessage(SERVICE_ON_STATE_CHANGE, b); + } + + //Wrapper to prevent threads racing + private void updateQueueWrapper() { + updateRequests.add(true); + if (!updating) { + updating = true; + while (!updateRequests.isEmpty()) { + updateQueue(); + //Because threading + if (!updateRequests.isEmpty()) + updateRequests.remove(0); + } + } + updating = false; + } + + //Loads downloads from database + private void loadDownloads() { + Cursor cursor = db.query("Downloads", null, null, null, null, null, null); + + //Parse downloads + while (cursor.moveToNext()) { + + //Duplicate check + int downloadId = cursor.getInt(0); + Download.DownloadState state = Download.DownloadState.values()[cursor.getInt(1)]; + boolean skip = false; + for (int i = 0; i < downloads.size(); i++) { + if (downloads.get(i).id == downloadId) { + if (downloads.get(i).state != state) { + //Different state, update state, only for finished/error + if (downloads.get(i).state.getValue() >= 3) { + downloads.set(i, Download.fromSQL(cursor)); + } + } + skip = true; + break; + } + } + //Add to queue + if (!skip) + downloads.add(Download.fromSQL(cursor)); + } + cursor.close(); + + updateState(); + } + + //Stop downloads + private void stop() { + running = false; + for (int i = 0; i < threads.size(); i++) { + threads.get(i).stopDownload(); + } + updateState(); + } + + + public class DownloadThread extends Thread { + + Download download; + File parentDir; + File outFile; + JSONObject trackJson; + JSONObject albumJson; + JSONObject privateJson; + JSONObject lyricsData = null; + boolean stopDownload = false; + + DownloadThread(Download download) { + this.download = download; + } + + @Override + public void run() { + //Set state + download.state = Download.DownloadState.DOWNLOADING; + + //Authorize deezer api + if (!deezer.authorized && !deezer.authorizing) + deezer.authorize(); + + while (deezer.authorizing) + try { + Thread.sleep(50); + } catch (Exception ignored) { + } + + //Don't fetch meta if user uploaded mp3 + if (!download.isUserUploaded()) { + try { + JSONObject privateRaw = deezer.callGWAPI("deezer.pageTrack", "{\"sng_id\": \"" + download.trackId + "\"}"); + privateJson = privateRaw.getJSONObject("results").getJSONObject("DATA"); + if (privateRaw.getJSONObject("results").has("LYRICS")) { + lyricsData = privateRaw.getJSONObject("results").getJSONObject("LYRICS"); + } + trackJson = deezer.callPublicAPI("track", download.trackId); + albumJson = deezer.callPublicAPI("album", Integer.toString(trackJson.getJSONObject("album").getInt("id"))); + + } catch (Exception e) { + logger.error("Unable to fetch track and album metadata! " + e.toString(), download); + e.printStackTrace(); + download.state = Download.DownloadState.ERROR; + exit(); + return; + } + } + + //Fallback + Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(this.download.quality, this.download.streamTrackId, this.download.trackToken, this.download.md5origin, this.download.mediaVersion, logger); + String sURL = null; + if (!download.isUserUploaded()) { + try { + sURL = qualityInfo.fallback(deezer); + if (sURL == null) + throw new Exception("No more to fallback!"); + + download.quality = qualityInfo.quality; + } catch (Exception e) { + logger.error("Fallback failed " + e.toString()); + download.state = Download.DownloadState.DEEZER_ERROR; + exit(); + return; + } + } else { + //User uploaded MP3 + qualityInfo.quality = 3; + } + + if (!download.priv) { + //Check file + try { + if (download.isUserUploaded()) { + outFile = new File(Deezer.generateUserUploadedMP3Filename(download.path, download.title)); + } else { + outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, qualityInfo.quality)); + } + parentDir = new File(outFile.getParent()); + } catch (Exception e) { + logger.error("Error generating track filename (" + download.path + "): " + e.toString(), download); + e.printStackTrace(); + download.state = Download.DownloadState.ERROR; + exit(); + return; + } + } else { + //Private track + outFile = new File(download.path); + parentDir = new File(outFile.getParent()); + } + //File already exists + if (outFile.exists()) { + //Delete if overwriting enabled + if (settings.overwriteDownload) { + outFile.delete(); + } else { + download.state = Download.DownloadState.DONE; + exit(); + return; + } + } + + //Temporary encrypted file + File tmpFile = new File(getCacheDir(), download.id + ".ENC"); + + //Get start bytes offset + long start = 0; + if (tmpFile.exists()) { + start = tmpFile.length(); + } + + //Download + try { + URL url = new URL(sURL); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + //Set headers + connection.setConnectTimeout(30000); + connection.setRequestMethod("GET"); + 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("Accept", "*/*"); + connection.setRequestProperty("Range", "bytes=" + start + "-"); + connection.connect(); + + //Open streams + BufferedInputStream inputStream = new BufferedInputStream(connection.getInputStream()); + OutputStream outputStream = new FileOutputStream(tmpFile.getPath(), true); + //Save total + download.filesize = start + connection.getContentLength(); + //Download + byte[] buffer = new byte[4096]; + long received = 0; + int read; + while ((read = inputStream.read(buffer, 0, 4096)) != -1) { + outputStream.write(buffer, 0, read); + received += read; + download.received = start + received; + + //Stop/Cancel download + if (stopDownload) { + download.state = Download.DownloadState.NONE; + try { + inputStream.close(); + outputStream.close(); + connection.disconnect(); + } catch (Exception ignored) { + } + exit(); + return; + } + } + //On done + inputStream.close(); + outputStream.close(); + connection.disconnect(); + //Update + download.state = Download.DownloadState.POST; + updateProgress(); + } catch (Exception e) { + //Download error + logger.error("Download error: " + e.toString(), download); + e.printStackTrace(); + download.state = Download.DownloadState.ERROR; + exit(); + return; + } + + //Post processing + + //Decrypt + if (qualityInfo.encrypted) { + try { + File decFile = new File(tmpFile.getPath() + ".DEC"); + DeezerDecryptor.decryptFile(download.streamTrackId, tmpFile.getPath(), decFile.getPath()); + tmpFile.delete(); + tmpFile = decFile; + } catch (Exception e) { + logger.error("Decryption error: " + e.toString(), download); + e.printStackTrace(); + //Shouldn't ever fail + } + } + + + //If exists (duplicate download in DB), don't overwrite. + if (outFile.exists()) { + download.state = Download.DownloadState.DONE; + exit(); + return; + } + + //Create dirs and copy + if (!parentDir.exists() && !parentDir.mkdirs()) { + //Log & Exit + logger.error("Couldn't create output folder: " + parentDir.getPath() + "! ", download); + download.state = Download.DownloadState.ERROR; + exit(); + return; + } + + if (!tmpFile.renameTo(outFile)) { + try { + //Copy file + FileInputStream inputStream = new FileInputStream(tmpFile); + FileOutputStream outputStream = new FileOutputStream(outFile); + FileChannel inputChannel = inputStream.getChannel(); + FileChannel outputChannel = outputStream.getChannel(); + inputChannel.transferTo(0, inputChannel.size(), outputChannel); + inputStream.close(); + outputStream.close(); + //Delete temp + tmpFile.delete(); + } catch (Exception e) { + //Clean + try { + outFile.delete(); + tmpFile.delete(); + } catch (Exception ignored) { + } + //Log & Exit + logger.error("Error moving file! " + outFile.getPath() + ", " + e.toString(), download); + e.printStackTrace(); + download.state = Download.DownloadState.ERROR; + exit(); + return; + } + } + + //Cover & Tags, ignore on user uploaded + if (!download.priv && !download.isUserUploaded()) { + + //Download cover for each track + File coverFile = new File(outFile.getPath().substring(0, outFile.getPath().lastIndexOf('.')) + ".jpg"); + + try { + URL url = new URL("http://e-cdn-images.deezer.com/images/cover/" + trackJson.getString("md5_image") + "/" + Integer.toString(settings.albumArtResolution) + "x" + Integer.toString(settings.albumArtResolution) + "-000000-80-0-0.jpg"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + //Set headers + connection.setRequestMethod("GET"); + connection.connect(); + //Open streams + InputStream inputStream = connection.getInputStream(); + OutputStream outputStream = new FileOutputStream(coverFile.getPath()); + //Download + byte[] buffer = new byte[4096]; + int read = 0; + while ((read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + } + //On done + try { + inputStream.close(); + outputStream.close(); + connection.disconnect(); + } catch (Exception ignored) { + } + + } catch (Exception e) { + logger.error("Error downloading cover! " + e.toString(), download); + e.printStackTrace(); + } + + //Lyrics + if (lyricsData != null) { + if (settings.downloadLyrics) { + try { + String lrcData = Deezer.generateLRC(lyricsData, trackJson); + //Create file + String lrcFilename = outFile.getPath().substring(0, outFile.getPath().lastIndexOf(".") + 1) + "lrc"; + FileOutputStream fileOutputStream = new FileOutputStream(lrcFilename); + fileOutputStream.write(lrcData.getBytes()); + fileOutputStream.close(); + + } catch (Exception e) { + logger.warn("Error downloading lyrics! " + e.toString(), download); + } + } + } + + //Tag + try { + deezer.tagTrack(outFile.getPath(), trackJson, albumJson, coverFile.getPath(), lyricsData, privateJson, settings); + } catch (Exception e) { + Log.e("ERR", "Tagging error!"); + e.printStackTrace(); + } + + //Delete cover if disabled + if (!settings.trackCover) + coverFile.delete(); + + //Album cover + if (settings.albumCover) + downloadAlbumCover(albumJson); + } + + download.state = Download.DownloadState.DONE; + //Queue update + updateQueueWrapper(); + stopSelf(); + } + + //Each track has own album art, this is to download cover.jpg + void downloadAlbumCover(JSONObject albumJson) { + //Checks + if (albumJson == null || !albumJson.has("md5_image")) return; + File coverFile = new File(parentDir, "cover.jpg"); + if (coverFile.exists()) return; + //Don't download if doesn't have album + if (!download.path.matches(".*/.*%album%.*/.*")) return; + + try { + //Create to lock + coverFile.createNewFile(); + + URL url = new URL("http://e-cdn-images.deezer.com/images/cover/" + albumJson.getString("md5_image") + "/" + Integer.toString(settings.albumArtResolution) + "x" + Integer.toString(settings.albumArtResolution) + "-000000-80-0-0.jpg"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + //Set headers + connection.setRequestMethod("GET"); + connection.connect(); + //Open streams + InputStream inputStream = connection.getInputStream(); + OutputStream outputStream = new FileOutputStream(coverFile.getPath()); + //Download + byte[] buffer = new byte[4096]; + int read = 0; + while ((read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + } + //On done + try { + inputStream.close(); + outputStream.close(); + connection.disconnect(); + } catch (Exception ignored) { + } + //Create .nomedia to not spam gallery + if (settings.nomediaFiles) + new File(parentDir, ".nomedia").createNewFile(); + } catch (Exception e) { + logger.warn("Error downloading album cover! " + e.toString(), download); + coverFile.delete(); + } + } + + void stopDownload() { + stopDownload = true; + } + + //Clean stop/exit + private void exit() { + updateQueueWrapper(); + stopSelf(); + } + + } + + //500ms loop to update notifications and UI + private void createProgressUpdateHandler() { + progressUpdateHandler.postDelayed(() -> { + updateProgress(); + createProgressUpdateHandler(); + }, 500); + } + + //Updates notification and UI + private void updateProgress() { + if (threads.size() > 0) { + //Convert threads to bundles, send to activity; + Bundle b = new Bundle(); + ArrayList down = new ArrayList<>(); + for (int i = 0; i < threads.size(); i++) { + //Create bundle + Download download = threads.get(i).download; + down.add(createProgressBundle(download)); + //Notification + updateNotification(download); + } + b.putParcelableArrayList("downloads", down); + sendMessage(SERVICE_ON_PROGRESS, b); + } + } + + //Create bundle with download progress & state + private Bundle createProgressBundle(Download download) { + Bundle bundle = new Bundle(); + bundle.putInt("id", download.id); + bundle.putLong("received", download.received); + bundle.putLong("filesize", download.filesize); + bundle.putInt("quality", download.quality); + bundle.putInt("state", download.state.getValue()); + return bundle; + } + + private void updateNotification(Download download) { + //Cancel notification for done/none/error downloads + if (download.state == Download.DownloadState.NONE || download.state.getValue() >= 3) { + notificationManager.cancel(NOTIFICATION_ID_START + download.id); + return; + } + + NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, DownloadService.NOTIFICATION_CHANNEL_ID) + .setContentTitle(download.title) + .setSmallIcon(R.drawable.ic_logo) + .setPriority(NotificationCompat.PRIORITY_MIN); + + //Show progress when downloading + if (download.state == Download.DownloadState.DOWNLOADING) { + if (download.filesize <= 0) download.filesize = 1; + notificationBuilder.setContentText(String.format("%s / %s", formatFilesize(download.received), formatFilesize(download.filesize))); + notificationBuilder.setProgress(100, (int) ((download.received / (float) download.filesize) * 100), false); + } + + //Indeterminate on PostProcess + if (download.state == Download.DownloadState.POST) { + //TODO: Use strings + notificationBuilder.setContentText("Post processing..."); + notificationBuilder.setProgress(1, 1, true); + } + + if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + 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; + if (downloads.isEmpty()) + loadDownloads(); + updateQueue(); + updateState(); + break; + + //Load settings + case SERVICE_SETTINGS_UPDATE: + settings = DownloadSettings.fromBundle(msg.getData()); + deezer.arl = settings.arl; + deezer.contentLanguage = settings.deezerLanguage; + 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; + boolean trackCover; + String arl; + boolean albumCover; + boolean nomediaFiles; + String artistSeparator; + int albumArtResolution; + String deezerLanguage = "en"; + String deezerCountry = "US"; + SelectedTags tags; + + private DownloadSettings(int downloadThreads, boolean overwriteDownload, boolean downloadLyrics, boolean trackCover, String arl, boolean albumCover, boolean nomediaFiles, String artistSeparator, int albumArtResolution, String deezerLanguage, String deezerCountry, SelectedTags tags) { + this.downloadThreads = downloadThreads; + this.overwriteDownload = overwriteDownload; + this.downloadLyrics = downloadLyrics; + this.trackCover = trackCover; + this.arl = arl; + this.albumCover = albumCover; + this.nomediaFiles = nomediaFiles; + this.artistSeparator = artistSeparator; + this.albumArtResolution = albumArtResolution; + this.deezerLanguage = deezerLanguage; + this.deezerCountry = deezerCountry; + this.tags = tags; + } + + //Parse settings from bundle sent from UI + static DownloadSettings fromBundle(Bundle b) { + JSONObject json; + try { + json = new JSONObject(b.getString("json")); + return new DownloadSettings( + json.getInt("downloadThreads"), + json.getBoolean("overwriteDownload"), + json.getBoolean("downloadLyrics"), + json.getBoolean("trackCover"), + json.getString("arl"), + json.getBoolean("albumCover"), + json.getBoolean("nomediaFiles"), + json.getString("artistSeparator"), + json.getInt("albumArtResolution"), + json.getString("deezerLanguage"), + json.getString("deezerCountry"), + new SelectedTags(json.getJSONArray("tags")) + ); + } catch (Exception e) { + //Shouldn't happen + Log.e("ERR", "Error loading settings!"); + return null; + } + } + } + + static class SelectedTags { + boolean title = false; + boolean album = false; + boolean artist = false; + boolean track = false; + boolean disc = false; + boolean albumArtist = false; + boolean date = false; + boolean label = false; + boolean isrc = false; + boolean upc = false; + boolean trackTotal = false; + boolean bpm = false; + boolean lyrics = false; + boolean genre = false; + boolean contributors = false; + boolean albumArt = false; + + SelectedTags(JSONArray json) { + //Array of tags, check if exist + try { + for (int i=0; i { + + //Add downloads to DB, then refresh service + if (call.method.equals("addDownloads")) { + ArrayList> downloads = call.arguments(); + + if (downloads != null) { + //TX + db.beginTransaction(); + for (int i = 0; i < downloads.size(); i++) { + //Check if exists + Cursor cursor = db.rawQuery("SELECT id, state, quality FROM Downloads WHERE trackId == ? AND path == ?", + new String[]{(String) downloads.get(i).get("trackId"), (String) downloads.get(i).get("path")}); + if (cursor.getCount() > 0) { + //If done or error, set state to NONE - they should be skipped because file exists + cursor.moveToNext(); + if (cursor.getInt(1) >= 3) { + ContentValues values = new ContentValues(); + values.put("state", 0); + values.put("quality", cursor.getInt(2)); + 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.putString("json", call.argument("json").toString()); + 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")) { + //Connected + sendMessage(DownloadService.SERVICE_START_DOWNLOAD, null); + result.success(serviceBound); + 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; + } + //If app was started with preload info (Android Auto) + if (call.method.equals("getPreloadInfo")) { + result.success(intentPreload); + intentPreload = null; + return; + } + //Get architecture + if (call.method.equals("arch")) { + result.success(System.getProperty("os.arch")); + return; + } + //Start streaming server + if (call.method.equals("startServer")) { + if (streamServer == null) { + //Get offline path + String offlinePath = getExternalFilesDir("offline").getAbsolutePath(); + //Start server + streamServer = new StreamServer(call.argument("arl"), offlinePath); + streamServer.start(); + } + result.success(null); + return; + } + //Get quality info from stream + if (call.method.equals("getStreamInfo")) { + if (streamServer == null) { + result.success(null); + return; + } + StreamServer.StreamInfo info = streamServer.streams.get(call.argument("id").toString()); + if (info != null) + result.success(info.toJSON()); + else + result.success(null); + return; + } + //Stop services + if (call.method.equals("kill")) { + Intent intent = new Intent(this, DownloadService.class); + stopService(intent); + if (streamServer != null) { + streamServer.stop(); + streamServer = null; + } + //System.exit(0); + 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; + } + })); + } + + //Start/Bind/Reconnect to download service + private void connectService() { + if (serviceBound) + return; + //Create messenger + activityMessenger = new Messenger(new IncomingHandler(this)); + //Start + Intent intent = new Intent(this, DownloadService.class); + intent.putExtra("activityMessenger", activityMessenger); + startService(intent); + bindService(intent, connection, BIND_AUTO_CREATE); + } + + @Override + protected void onStart() { + super.onStart(); + + connectService(); + //Get DB (and leave open!) + DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext()); + db = dbHelper.getWritableDatabase(); + + //Trust all SSL Certs - Credits to Kilowatt36 + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return null; + } + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + } + }; + SSLContext sc; + try { + sc = SSLContext.getInstance("SSL"); + sc.init(null, trustAllCerts, new java.security.SecureRandom()); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + Log.e(this.getLocalClassName(), e.getMessage()); + } + } + + @Override + protected void onResume() { + super.onResume(); + //Try reconnect + connectService(); + } + + @Override + protected void onStop() { + super.onStop(); + db.close(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + //Stop server + if (streamServer != null) + streamServer.stop(); + + //Unbind service on exit + if (serviceBound) { + unbindService(connection); + serviceBound = false; + } + } + + //Connection to download service + private final ServiceConnection connection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + serviceMessenger = new Messenger(iBinder); + serviceBound = true; + Log.d("DD", "Service Bound!"); + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + serviceMessenger = null; + serviceBound = false; + Log.d("DD", "Service UnBound!"); + } + }; + + //Handler for incoming messages from service + private static class IncomingHandler extends Handler { + private final WeakReference weakReference; + IncomingHandler(MainActivity activity) { + super(Looper.getMainLooper()); + this.weakReference = new WeakReference<>(activity); + } + + @Override + public void handleMessage(@NonNull Message msg) { + MainActivity activity = weakReference.get(); + + if (activity != null) { + EventChannel.EventSink eventSink = activity.eventSink; + switch (msg.what) { + //Forward to flutter. + case DownloadService.SERVICE_ON_PROGRESS: + if (eventSink == null) break; + ArrayList downloads = getParcelableArrayList(msg.getData(), "downloads", Bundle.class); + if (downloads != null && downloads.size() > 0) { + //Generate HashMap ArrayList for sending to flutter + ArrayList> data = new ArrayList<>(); + for (Bundle bundle : downloads) { + HashMap out = new HashMap<>(); + out.put("id", bundle.getInt("id")); + out.put("state", bundle.getInt("state")); + out.put("received", bundle.getLong("received")); + out.put("filesize", bundle.getLong("filesize")); + out.put("quality", bundle.getInt("quality")); + data.add(out); + } + //Wrapper + HashMap out = new HashMap<>(); + out.put("action", "onProgress"); + out.put("data", data); + eventSink.success(out); + } + + break; + //State change, forward to flutter + case DownloadService.SERVICE_ON_STATE_CHANGE: + if (eventSink == null) break; + Bundle b = msg.getData(); + HashMap out = new HashMap<>(); + out.put("running", b.getBoolean("running")); + out.put("queueSize", b.getInt("queueSize")); + + //Wrapper info + out.put("action", "onStateChange"); + + eventSink.success(out); + break; + + default: + super.handleMessage(msg); + } + } + } + } + + //Send message to service + void sendMessage(int type, Bundle data) { + if (serviceBound && serviceMessenger != null) { + Message msg = Message.obtain(null, type); + msg.setData(data); + try { + serviceMessenger.send(msg); + } catch (RemoteException e) { + e.printStackTrace(); + } + } + } + + @Nullable + public static ArrayList getParcelableArrayList(@Nullable Bundle bundle, @Nullable String key, @NonNull Class clazz) { + if (bundle != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return bundle.getParcelableArrayList(key, clazz); + } else { + return bundle.getParcelableArrayList(key); + } + } + return null; + } +} diff --git a/android/app/src/main/java/r/r/refreezer/StreamServer.java b/android/app/src/main/java/r/r/refreezer/StreamServer.java new file mode 100644 index 0000000..705bd87 --- /dev/null +++ b/android/app/src/main/java/r/r/refreezer/StreamServer.java @@ -0,0 +1,316 @@ +package r.r.refreezer; + +import android.content.pm.PackageManager; +import android.util.Log; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.net.URL; +import java.util.HashMap; +import java.util.Objects; + +import javax.net.ssl.HttpsURLConnection; +import fi.iki.elonen.NanoHTTPD; + +public class StreamServer { + + public HashMap streams = new HashMap<>(); + + private WebServer server; + private final String offlinePath; + + //Shared log & API + private final DownloadLog logger; + private final Deezer deezer; + private boolean authorized = false; + + StreamServer(String arl, String offlinePath) { + //Initialize shared variables + logger = new DownloadLog(); + deezer = new Deezer(); + deezer.init(logger, arl); + this.offlinePath = offlinePath; + } + + //Create server + void start() { + try { + String host = "127.0.0.1"; + int port = 36958; + server = new WebServer(host, port); + server.start(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + void stop() { + if (server != null) + server.stop(); + } + + //Information about streamed audio - for showing in UI + public static class StreamInfo { + String format; + long size; + //"Stream" or "Offline" + String source; + + StreamInfo(String format, long size, String source) { + this.format = format; + this.size = size; + this.source = source; + } + + //For passing into UI + public HashMap toJSON() { + HashMap out = new HashMap<>(); + out.put("format", format); + out.put("size", size); + out.put("source", source); + return out; + } + + } + + private class WebServer extends NanoHTTPD { + public WebServer(String hostname, int port) { + super(hostname, port); + } + + @Override + public Response serve(IHTTPSession session) { + //Must be only GET + if (session.getMethod() != Method.GET) + return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, MIME_PLAINTEXT, "Only GET request supported!"); + + try { + //Parse range header + String rangeHeader = session.getHeaders().get("range"); + int startBytes = 0; + boolean isRanged = false; + int end = -1; + if (rangeHeader != null && rangeHeader.startsWith("bytes")) { + isRanged = true; + String[] ranges = rangeHeader.split("=")[1].split("-"); + startBytes = Integer.parseInt(ranges[0]); + if (ranges.length > 1 && !ranges[1].equals(" ")) { + end = Integer.parseInt(ranges[1]); + } + } + + //Check query parameters + if (session.getParameters().keySet().size() < 6) { + //Play offline + if (session.getParameters().get("id") != null) { + return offlineStream(session, startBytes, end, isRanged); + } + //Missing QP + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid / Missing QP"); + } + + //Stream + return deezerStream(session, startBytes, end, isRanged); + } catch (Exception e) { + e.printStackTrace(); + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "An error occurred while serving the request."); + } + } + + private Response offlineStream(IHTTPSession session, int startBytes, int end, boolean isRanged) { + //Get path + String trackId = Objects.requireNonNull(session.getParameters().get("id")).get(0); + File file = new File(offlinePath, trackId); + long size = file.length(); + //Read header + boolean isFlac = false; + try { + InputStream inputStream = new FileInputStream(file); + byte[] buffer = new byte[4]; + inputStream.read(buffer, 0, 4); + inputStream.close(); + if (new String(buffer).equals("fLaC")) + isFlac = true; + } catch (Exception e) { + Log.d("StreamServer", "Invalid offline file: " + e.getMessage()); + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid offline file!"); + } + //Open file + RandomAccessFile randomAccessFile; + try { + randomAccessFile = new RandomAccessFile(file, "r"); + randomAccessFile.seek(startBytes); + } catch (Exception e) { + Log.d("StreamServer", "Failed getting offline data: " + e.getMessage()); + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!"); + } + + //Generate response + Response response = newFixedLengthResponse( + isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK, + isFlac ? "audio/flac" : "audio/mpeg", + new InputStream() { + @Override + public int read() throws IOException { + return 0; + } + //Pass thru + @Override + public int read(byte[] b, int off, int len) throws IOException { + return randomAccessFile.read(b, off, len); + } + }, + ((end == -1) ? size : end) - startBytes + ); + //Ranged header + if (isRanged) { + String range = "bytes " + Integer.toString(startBytes) + "-" + Long.toString((end == -1) ? size - 1 : end); + range += "/" + Long.toString(size); + response.addHeader("Content-Range", range); + } + response.addHeader("Accept-Ranges", "bytes"); + + //Save stream info + streams.put(trackId, new StreamInfo((isFlac ? "FLAC" : "MP3"), size, "Offline")); + + return response; + } + + private Response deezerStream(IHTTPSession session, int startBytes, int end, boolean isRanged) { + // Authorize + if (!authorized) { + deezer.authorize(); + authorized = true; + } + + //Get QP into Quality Info + Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo( + Integer.parseInt(Objects.requireNonNull(session.getParameters().get("q")).get(0)), + Objects.requireNonNull(session.getParameters().get("streamTrackId")).get(0), + Objects.requireNonNull(session.getParameters().get("trackToken")).get(0), + Objects.requireNonNull(session.getParameters().get("md5origin")).get(0), + Objects.requireNonNull(session.getParameters().get("mv")).get(0), + logger + ); + //Fallback + String sURL; + try { + sURL = qualityInfo.fallback(deezer); + if (sURL == null) + throw new Exception("No more to fallback!"); + } catch (Exception e) { + return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Fallback failed!"); + } + + //Calculate Deezer offsets + int _deezerStart = startBytes; + if (qualityInfo.encrypted) + _deezerStart -= startBytes % 2048; + final int deezerStart = _deezerStart; + int dropBytes = startBytes % 2048; + + //Start download + try { + URL url = new URL(sURL); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + //Set headers + connection.setConnectTimeout(10000); + connection.setRequestMethod("GET"); + 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("Accept", "*/*"); + connection.setRequestProperty("Range", "bytes=" + Integer.toString(deezerStart) + "-" + ((end == -1) ? "" : Integer.toString(end))); + connection.connect(); + + Response outResponse; + // Encrypted response + if (qualityInfo.encrypted) { + //Get decryption key + final byte[] key = DeezerDecryptor.getKey(qualityInfo.trackId); + + outResponse = newFixedLengthResponse( + isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK, + (qualityInfo.quality == 9) ? "audio/flac" : "audio/mpeg", + new BufferedInputStream(new FilterInputStream(connection.getInputStream()) { + + int counter = deezerStart / 2048; + int drop = dropBytes; + + //Decryption stream + @Override + public int read(byte[] b, int off, int len) throws IOException { + //Read 2048b or EOF + byte[] buffer = new byte[2048]; + int read = 0; + int totalRead = 0; + while (read != -1 && totalRead != 2048) { + read = in.read(buffer, totalRead, 2048 - totalRead); + if (read != -1) + totalRead += read; + } + if (totalRead == 0) + return -1; + + //Not full chunk return unencrypted + if (totalRead != 2048) { + System.arraycopy(buffer, 0, b, off, totalRead); + return totalRead; + } + //Decrypt every 3rd full chunk + if ((counter % 3) == 0) { + buffer = DeezerDecryptor.decryptChunk(key, buffer); + } + //Drop bytes from rounding to 2048 + if (drop > 0) { + int output = 2048 - drop; + System.arraycopy(buffer, drop, b, off, output); + drop = 0; + counter++; + return output; + } + //Copy + System.arraycopy(buffer, 0, b, off, 2048); + counter++; + return 2048; + } + }, 2048), + connection.getContentLength() - dropBytes + ); + } else { + // Decrypted + outResponse = newFixedLengthResponse( + isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK, + (qualityInfo.quality == 9) ? "audio/flac" : "audio/mpeg", + connection.getInputStream(), + connection.getContentLength() + ); + } + + //Ranged header + if (isRanged) { + String range = "bytes " + Integer.toString(startBytes) + "-" + Integer.toString((end == -1) ? (connection.getContentLength() + deezerStart) - 1 : end); + range += "/" + Integer.toString(connection.getContentLength() + deezerStart); + outResponse.addHeader("Content-Range", range); + } + outResponse.addHeader("Accept-Ranges", "bytes"); + + //Save stream info, use original track id since this is used to communicate with Flutter UI + streams.put(Objects.requireNonNull(session.getParameters().get("id")).get(0), new StreamInfo( + ((qualityInfo.quality == 9) ? "FLAC" : "MP3"), + deezerStart + connection.getContentLength(), + "Stream" + )); + + return outResponse; + } catch (Exception e) { + e.printStackTrace(); + } + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!"); + } + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi-v24/ic_favorite.xml b/android/app/src/main/res/drawable-anydpi-v24/ic_favorite.xml new file mode 100644 index 0000000..89689a6 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v24/ic_favorite.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/ic_action_stop.png b/android/app/src/main/res/drawable-hdpi/ic_action_stop.png new file mode 100644 index 0000000..4b62ed2 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_action_stop.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_favorite.png b/android/app/src/main/res/drawable-hdpi/ic_favorite.png new file mode 100644 index 0000000..916406d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_favorite.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_logo.png b/android/app/src/main/res/drawable-hdpi/ic_logo.png new file mode 100644 index 0000000..691dd95 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_action_stop.png b/android/app/src/main/res/drawable-mdpi/ic_action_stop.png new file mode 100644 index 0000000..72a384c Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_action_stop.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_favorite.png b/android/app/src/main/res/drawable-mdpi/ic_favorite.png new file mode 100644 index 0000000..300de71 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_favorite.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_logo.png b/android/app/src/main/res/drawable-mdpi/ic_logo.png new file mode 100644 index 0000000..32d0fd5 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_logo.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_action_stop.png b/android/app/src/main/res/drawable-xhdpi/ic_action_stop.png new file mode 100644 index 0000000..2048eb8 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_action_stop.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_favorite.png b/android/app/src/main/res/drawable-xhdpi/ic_favorite.png new file mode 100644 index 0000000..bea0ba1 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_favorite.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_logo.png b/android/app/src/main/res/drawable-xhdpi/ic_logo.png new file mode 100644 index 0000000..5da585d Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_action_stop.png b/android/app/src/main/res/drawable-xxhdpi/ic_action_stop.png new file mode 100644 index 0000000..ed196b8 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_action_stop.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_favorite.png b/android/app/src/main/res/drawable-xxhdpi/ic_favorite.png new file mode 100644 index 0000000..fa6059c Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_favorite.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_logo.png b/android/app/src/main/res/drawable-xxhdpi/ic_logo.png new file mode 100644 index 0000000..1611d13 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_action_stop.png b/android/app/src/main/res/drawable-xxxhdpi/ic_action_stop.png new file mode 100644 index 0000000..efb0abd Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_action_stop.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png b/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png new file mode 100644 index 0000000..78a8ea4 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png differ diff --git a/android/app/src/main/res/drawable/ic_flow_background.xml b/android/app/src/main/res/drawable/ic_flow_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_flow_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_favorites.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_favorites.xml new file mode 100644 index 0000000..279e6ca --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_favorites.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_favorites_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_favorites_round.xml new file mode 100644 index 0000000..279e6ca --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_favorites_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_flow.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_flow.xml new file mode 100644 index 0000000..cf10bec --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_flow.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_flow_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_flow_round.xml new file mode 100644 index 0000000..cf10bec --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_flow_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_favorites_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_favorites_foreground.png new file mode 100644 index 0000000..6a1b908 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_favorites_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_favorites_round.png b/android/app/src/main/res/mipmap-hdpi/ic_favorites_round.png new file mode 100644 index 0000000..030466e Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_favorites_round.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_flow_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_flow_foreground.png new file mode 100644 index 0000000..c2fc346 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_flow_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_flow_round.png b/android/app/src/main/res/mipmap-hdpi/ic_flow_round.png new file mode 100644 index 0000000..05a9d9b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_flow_round.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..80254ed Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7bfd6f7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..bb82d93 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_favorites_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_favorites_foreground.png new file mode 100644 index 0000000..044d0f0 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_favorites_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_favorites_round.png b/android/app/src/main/res/mipmap-mdpi/ic_favorites_round.png new file mode 100644 index 0000000..c26912f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_favorites_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_flow_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_flow_foreground.png new file mode 100644 index 0000000..b7deba7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_flow_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_flow_round.png b/android/app/src/main/res/mipmap-mdpi/ic_flow_round.png new file mode 100644 index 0000000..4df0b7a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_flow_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..134334d Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ba1669c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ce5e2ef Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_favorites_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_favorites_foreground.png new file mode 100644 index 0000000..1b8266e Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_favorites_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_favorites_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_favorites_round.png new file mode 100644 index 0000000..d77c379 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_favorites_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_flow_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_flow_foreground.png new file mode 100644 index 0000000..69cfc68 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_flow_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_flow_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_flow_round.png new file mode 100644 index 0000000..67aa802 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_flow_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..967e08a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..72be683 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..37abb73 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_favorites_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_favorites_foreground.png new file mode 100644 index 0000000..46daa3e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_favorites_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_favorites_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_favorites_round.png new file mode 100644 index 0000000..a5855a3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_favorites_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_flow_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_flow_foreground.png new file mode 100644 index 0000000..da89371 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_flow_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_flow_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_flow_round.png new file mode 100644 index 0000000..082aa8a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_flow_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..79a2b3d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..02a80a5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..6be04b4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_favorites_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_favorites_foreground.png new file mode 100644 index 0000000..f58c628 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_favorites_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_favorites_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_favorites_round.png new file mode 100644 index 0000000..836bdd2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_favorites_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_flow_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_flow_foreground.png new file mode 100644 index 0000000..bc819c6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_flow_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_flow_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_flow_round.png new file mode 100644 index 0000000..2be273a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_flow_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..801d216 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1fc6edf Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8d56068 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/ic_favorites_background.xml b/android/app/src/main/res/values/ic_favorites_background.xml new file mode 100644 index 0000000..e5d934a --- /dev/null +++ b/android/app/src/main/res/values/ic_favorites_background.xml @@ -0,0 +1,4 @@ + + + #3DDC84 + \ No newline at end of file diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..e2f223d --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1C1C14 + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1f83a33 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..e2db349 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..67101ba --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,56 @@ +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://jitpack.io' } + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" + + // This will make sure all the sub projects have an android namespace, + // if not it will read it from AndroidManifest.xml + // Also sets the compileSdk to 34 and the target javaVersion to 17 + // (needs to be changed manually to the latest in the future) + afterEvaluate { + // check if android block is available + if (it.hasProperty('android')) { + + if (it.android.namespace == null) { + def manifest = groovy.util.XmlSlurper.parse(file(it.android.sourceSets.main.manifest.srcFile)) + def packageName = manifest.@package.text() + println("Setting ${packageName} as android namespace") + android.namespace = packageName + } + + def javaVersion = JavaVersion.VERSION_1_8 + android { + def androidApiVersion = 34 + compileSdk androidApiVersion + defaultConfig { + targetSdk androidApiVersion + } + compileOptions { + sourceCompatibility javaVersion + targetCompatibility javaVersion + } + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = javaVersion.toString() + } + } + println("Setting java version to ${javaVersion.toString()} which is $javaVersion") + println("Setting compileSdk and targetSdk to $androidApiVersion") + } + } + } +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..d8fed88 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=false +android.nonTransitiveRClass=false +android.nonFinalResIds=false \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c85cfe --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..675337f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version '8.5.1' apply false + id "org.jetbrains.kotlin.android" version "1.9.24" apply false +} + +include ":app" \ No newline at end of file diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..fac6d23 Binary files /dev/null and b/assets/banner.png differ diff --git a/assets/browse_icon.png b/assets/browse_icon.png new file mode 100644 index 0000000..8225b53 Binary files /dev/null and b/assets/browse_icon.png differ diff --git a/assets/cover.jpg b/assets/cover.jpg new file mode 100644 index 0000000..6ca5067 Binary files /dev/null and b/assets/cover.jpg differ diff --git a/assets/cover_thumb.jpg b/assets/cover_thumb.jpg new file mode 100644 index 0000000..584350e Binary files /dev/null and b/assets/cover_thumb.jpg differ diff --git a/assets/favorites_thumb.jpg b/assets/favorites_thumb.jpg new file mode 100644 index 0000000..84a4f7d Binary files /dev/null and b/assets/favorites_thumb.jpg differ diff --git a/assets/fonts/MabryPro.otf b/assets/fonts/MabryPro.otf new file mode 100644 index 0000000..c922119 Binary files /dev/null and b/assets/fonts/MabryPro.otf differ diff --git a/assets/fonts/MabryProBlack.otf b/assets/fonts/MabryProBlack.otf new file mode 100644 index 0000000..63ea34e Binary files /dev/null and b/assets/fonts/MabryProBlack.otf differ diff --git a/assets/fonts/MabryProBold.otf b/assets/fonts/MabryProBold.otf new file mode 100644 index 0000000..3de5235 Binary files /dev/null and b/assets/fonts/MabryProBold.otf differ diff --git a/assets/fonts/MabryProItalic.otf b/assets/fonts/MabryProItalic.otf new file mode 100644 index 0000000..2886b69 Binary files /dev/null and b/assets/fonts/MabryProItalic.otf differ diff --git a/assets/fonts/ReFreezerIcons.ttf b/assets/fonts/ReFreezerIcons.ttf new file mode 100644 index 0000000..1d92521 Binary files /dev/null and b/assets/fonts/ReFreezerIcons.ttf differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..42ba89e Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon_legacy.png b/assets/icon_legacy.png new file mode 100644 index 0000000..62b09ec Binary files /dev/null and b/assets/icon_legacy.png differ diff --git a/lib/api/cache.dart b/lib/api/cache.dart new file mode 100644 index 0000000..a3f644a --- /dev/null +++ b/lib/api/cache.dart @@ -0,0 +1,166 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_annotation/json_annotation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../api/definitions.dart'; + +part 'cache.g.dart'; + +late 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(includeFromJson: false) + @JsonKey(includeToJson: false) + String? loggedTrackId; + + @JsonKey(defaultValue: []) + List history = []; + + //All sorting cached + @JsonKey(defaultValue: []) + List sorts = []; + + //Sleep timer + @JsonKey(includeFromJson: false) + @JsonKey(includeToJson: false) + DateTime? sleepTimerTime; + @JsonKey(includeFromJson: false) + @JsonKey(includeToJson: false) + StreamSubscription? sleepTimer; + + //Search history + @JsonKey(name: 'searchHistory2', toJson: _searchHistoryToJson, fromJson: _searchHistoryFromJson) + List? searchHistory; + + //If download threads warning was shown + @JsonKey(defaultValue: false) + bool threadsWarning = false; + + //Last time update check + @JsonKey(defaultValue: 0) + int? lastUpdateCheck; + + @JsonKey(includeFromJson: false) + @JsonKey(includeToJson: false) + bool wakelock = false; + + Cache({this.libraryTracks}); + + //Wrapper to test if track is favorite against cache + bool checkTrackFavorite(Track t) { + if ((t.favorite ?? false)) return true; + if (libraryTracks == null || libraryTracks!.isEmpty) return false; + return libraryTracks!.contains(t.id); + } + + //Add to history + void addToSearchHistory(dynamic item) async { + searchHistory ??= []; + + // Remove duplicate + int i = searchHistory!.indexWhere((e) => e.data.id == item.id); + if (i != -1) { + searchHistory!.removeAt(i); + } + + if (item is Track) { + searchHistory!.add(SearchHistoryItem(item, SearchHistoryItemType.TRACK)); + } + if (item is Album) { + searchHistory!.add(SearchHistoryItem(item, SearchHistoryItemType.ALBUM)); + } + if (item is Artist) { + searchHistory!.add(SearchHistoryItem(item, SearchHistoryItemType.ARTIST)); + } + if (item is Playlist) { + searchHistory!.add(SearchHistoryItem(item, SearchHistoryItemType.PLAYLIST)); + } + + await save(); + } + + //Save, load + static Future getPath() async { + return p.join((await getApplicationDocumentsDirectory()).path, 'metacache.json'); + } + + static Future wipe() async { + String cacheFilePath = await Cache.getPath(); + if (await File(cacheFilePath).exists()) { + await File(cacheFilePath).delete(); + } + } + + 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; + } + Map cacheJson = {}; + String fileContent = await file.readAsString(); + if (fileContent.isNotEmpty) { + cacheJson = jsonDecode(fileContent); + } + return Cache.fromJson(cacheJson); + } + + Future save() async { + File file = File(await Cache.getPath()); + file.writeAsString(jsonEncode(toJson())); + } + + //JSON + factory Cache.fromJson(Map json) => _$CacheFromJson(json); + Map toJson() => _$CacheToJson(this); + + //Search History JSON + static List _searchHistoryFromJson(List? json) { + return (json ?? []).map((i) => _searchHistoryItemFromJson(i)).toList(); + } + + static SearchHistoryItem _searchHistoryItemFromJson(Map json) { + SearchHistoryItemType type = SearchHistoryItemType.values[json['type']]; + dynamic data; + switch (type) { + case SearchHistoryItemType.TRACK: + data = Track.fromJson(json['data']); + break; + case SearchHistoryItemType.ALBUM: + data = Album.fromJson(json['data']); + break; + case SearchHistoryItemType.ARTIST: + data = Artist.fromJson(json['data']); + break; + case SearchHistoryItemType.PLAYLIST: + data = Playlist.fromJson(json['data']); + break; + } + return SearchHistoryItem(data, type); + } + + static List> _searchHistoryToJson(List? data) => + (data ?? []).map>((i) => {'type': i.type.index, 'data': i.data.toJson()}).toList(); +} + +@JsonSerializable() +class SearchHistoryItem { + dynamic data; + SearchHistoryItemType type; + + SearchHistoryItem(this.data, this.type); +} + +enum SearchHistoryItemType { TRACK, ALBUM, ARTIST, PLAYLIST } diff --git a/lib/api/cache.g.dart b/lib/api/cache.g.dart new file mode 100644 index 0000000..bdddd38 --- /dev/null +++ b/lib/api/cache.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Cache _$CacheFromJson(Map json) => Cache( + libraryTracks: (json['libraryTracks'] as List?) + ?.map((e) => e as String) + .toList(), + ) + ..history = (json['history'] as List?) + ?.map((e) => Track.fromJson(e as Map)) + .toList() ?? + [] + ..sorts = (json['sorts'] as List?) + ?.map((e) => Sorting.fromJson(e as Map)) + .toList() ?? + [] + ..searchHistory = + Cache._searchHistoryFromJson(json['searchHistory2'] as List?) + ..threadsWarning = json['threadsWarning'] as bool? ?? false + ..lastUpdateCheck = (json['lastUpdateCheck'] as num?)?.toInt() ?? 0; + +Map _$CacheToJson(Cache instance) => { + 'libraryTracks': instance.libraryTracks, + 'history': instance.history, + 'sorts': instance.sorts, + 'searchHistory2': Cache._searchHistoryToJson(instance.searchHistory), + 'threadsWarning': instance.threadsWarning, + 'lastUpdateCheck': instance.lastUpdateCheck, + }; + +SearchHistoryItem _$SearchHistoryItemFromJson(Map json) => + SearchHistoryItem( + json['data'], + $enumDecode(_$SearchHistoryItemTypeEnumMap, json['type']), + ); + +Map _$SearchHistoryItemToJson(SearchHistoryItem instance) => + { + 'data': instance.data, + 'type': _$SearchHistoryItemTypeEnumMap[instance.type]!, + }; + +const _$SearchHistoryItemTypeEnumMap = { + SearchHistoryItemType.TRACK: 'TRACK', + SearchHistoryItemType.ALBUM: 'ALBUM', + SearchHistoryItemType.ARTIST: 'ARTIST', + SearchHistoryItemType.PLAYLIST: 'PLAYLIST', +}; diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart new file mode 100644 index 0000000..4c6254e --- /dev/null +++ b/lib/api/deezer.dart @@ -0,0 +1,690 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import '../api/definitions.dart'; +import '../api/spotify.dart'; +import '../settings.dart'; + +DeezerAPI deezerAPI = DeezerAPI(); + +class DeezerAPI { + DeezerAPI({this.arl}); + + static const String userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36'; + + String? arl; + String? token; + String? licenseToken; + String? userId; + String? userName; + String? favoritesPlaylistId; + String? sid; + + Future? _authorizing; + + //Get headers + Map get headers => { + 'User-Agent': DeezerAPI.userAgent, + 'Content-Language': + '${settings.deezerLanguage}-${settings.deezerCountry}', + 'Content-Type': 'text/plain;charset=UTF-8', + //'origin': 'https://www.deezer.com', + //'Cache-Control': 'max-age=0', + 'Accept': '*/*', + 'Accept-Charset': 'utf-8,ISO-8859-1;q=0.7,*;q=0.3', + 'Accept-Language': + '${settings.deezerLanguage}-${settings.deezerCountry},${settings.deezerLanguage};q=0.9,en-US;q=0.8,en;q=0.7', + 'Connection': 'keep-alive', + //'sec-fetch-site': 'same-origin', + //'sec-fetch-mode': 'same-origin', + //'sec-fetch-dest': 'empty', + //'referer': 'https://www.deezer.com/', + 'Cookie': 'arl=$arl' + ((sid == null) ? '' : '; sid=$sid') + }; + + //Call private GW-light API + Future> callGwApi(String method, + {Map? params, String? gatewayInput}) async { + //Generate URL + Uri uri = Uri.https('www.deezer.com', '/ajax/gw-light.php', { + 'api_version': '1.0', + 'api_token': token, + 'input': '3', + 'method': method, + 'cid': Random().nextInt(1000000000).toString(), + //Used for homepage + if (gatewayInput != null) 'gateway_input': gatewayInput + }); + //Post + http.Response res = + await http.post(uri, headers: headers, body: jsonEncode(params)); + dynamic body = jsonDecode(res.body); + //Grab SID + if (method == 'deezer.getUserData' && res.headers['set-cookie'] != null) { + for (String cookieHeader in res.headers['set-cookie']!.split(';')) { + if (cookieHeader.startsWith('sid=')) { + sid = cookieHeader.split('=')[1]; + } + } + } + // In case of error "Invalid CSRF token" retrieve new one and retry the same call + // Except for "deezer.getUserData" method, which would cause infinite loop + if (body['error'].isNotEmpty && + body['error'].containsKey('VALID_TOKEN_REQUIRED') && + (method != 'deezer.getUserData' && await rawAuthorize())) { + return callGwApi(method, params: params, gatewayInput: gatewayInput); + } + return body; + } + + Future> callPublicApi(String path) async { + http.Response res = + await http.get(Uri.parse('https://api.deezer.com/' + path)); + return jsonDecode(res.body); + } + + Future getJsonWebToken() async { + //Generate URL + //Uri uri = Uri.parse('https://auth.deezer.com/login/arl?jo=p&rto=c&i=c'); + Uri uri = Uri.https( + 'auth.deezer.com', '/login/arl', {'jo': 'p', 'rto': 'c', 'i': 'c'}); + //Post + http.Response res = await http.post(uri, headers: headers); + dynamic body = jsonDecode(res.body); + //Grab jwt token + if (body['jwt']?.isNotEmpty) { + return body['jwt']; + } + return ''; + } + + //Call private pipe API + Future> callPipeApi( + {Map? params}) async { + //Get jwt auth token + String jwtToken = await getJsonWebToken(); + Map pipeApiHeaders = headers; + // Add jwt token to headers + pipeApiHeaders['Authorization'] = 'Bearer $jwtToken'; + // Change Content-Type to application/json + pipeApiHeaders['Content-Type'] = 'application/json'; + //Generate URL + //Uri uri = Uri.parse('https://pipe.deezer.com/api'); + Uri uri = Uri.https('pipe.deezer.com', '/api/'); + //Post + http.Response res = + await http.post(uri, headers: pipeApiHeaders, body: jsonEncode(params)); + dynamic body = jsonDecode(res.body); + + return body; + } + + //Wrapper so it can be globally awaited + Future authorize() async { + return _authorizing ??= rawAuthorize(); + } + + //Authorize, bool = success + Future rawAuthorize({Function? onError}) async { + try { + Map data = await callGwApi('deezer.getUserData'); + if (data['results']['USER']['USER_ID'] == 0) { + return false; + } else { + token = data['results']['checkForm']; + userId = data['results']['USER']['USER_ID']?.toString() ?? ''; + userName = data['results']['USER']['BLOG_NAME']; + favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID']; + licenseToken = data['results']['USER']['OPTIONS']['license_token']; + return true; + } + } catch (e) { + if (onError != null) { + onError(e); + } + Logger.root.severe('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') { + http.BaseRequest request = http.Request('HEAD', Uri.parse(url)); + request.followRedirects = false; + http.StreamedResponse response = await request.send(); + String newUrl = response.headers['location'] ?? ''; + return parseLink(newUrl); + } + //Spotify + if (uri.host == 'open.spotify.com') { + if (uri.pathSegments.length < 2) return null; + String spotifyUri = 'spotify:' + uri.pathSegments.sublist(0, 2).join(':'); + try { + //Tracks + if (uri.pathSegments[0] == 'track') { + String id = await SpotifyScrapper.convertTrack(spotifyUri); + return DeezerLinkResponse(type: DeezerLinkType.TRACK, id: id); + } + //Albums + if (uri.pathSegments[0] == 'album') { + String id = await SpotifyScrapper.convertAlbum(spotifyUri); + return DeezerLinkResponse(type: DeezerLinkType.ALBUM, id: id); + } + } catch (e) { + Logger.root.severe('Error converting Spotify results: ' + e.toString()); + } + } + return null; + } + + //Check if Deezer available in country + static Future checkAvailability() async { + try { + http.Response res = + await http.get(Uri.parse('https://api.deezer.com/infos')); + return jsonDecode(res.body)['open']; + } catch (e) { + return null; + } + } + + //Search + Future search(String query) async { + Map data = await callGwApi('deezer.pageSearch', + params: {'nb': 128, 'query': query, 'start': 0}); + return SearchResults.fromPrivateJson(data['results']); + } + + Future track(String id) async { + Map data = await callGwApi('song.getListData', params: { + 'sng_ids': [id] + }); + return Track.fromPrivateJson(data['results']['data'][0]); + } + + //Get album details, tracks + Future album(String id) async { + Map data = await callGwApi('deezer.pageAlbum', params: { + 'alb_id': id, + 'header': true, + 'lang': settings.deezerLanguage + }); + return Album.fromPrivateJson(data['results']['DATA'], + songsJson: data['results']['SONGS']); + } + + //Get artist details + Future artist(String id) async { + Map data = await callGwApi('deezer.pageArtist', params: { + 'art_id': id, + 'lang': settings.deezerLanguage, + }); + return Artist.fromPrivateJson(data['results']['DATA'], + topJson: data['results']['TOP'], + albumsJson: data['results']['ALBUMS'], + highlight: data['results']['HIGHLIGHT']); + } + + //Get playlist tracks at offset + Future> playlistTracksPage(String id, int start, + {int nb = 50}) async { + Map data = await callGwApi('deezer.pagePlaylist', params: { + 'playlist_id': id, + 'lang': settings.deezerLanguage, + 'nb': nb, + 'tags': true, + 'start': start + }); + return data['results']['SONGS']['data'] + .map((json) => Track.fromPrivateJson(json)) + .toList(); + } + + //Get playlist details + Future playlist(String id, {int nb = 100}) async { + Map data = + await callGwApi('deezer.pagePlaylist', params: { + 'playlist_id': id, + 'lang': settings.deezerLanguage, + 'nb': nb, + 'tags': true, + 'start': 0 + }); + return Playlist.fromPrivateJson(data['results']['DATA'], + songsJson: data['results']['SONGS']); + } + + //Get playlist with all tracks + Future fullPlaylist(String id) async { + return await playlist(id, nb: 100000); + } + + //Add track to favorites + Future addFavoriteTrack(String id) async { + await callGwApi('favorite_song.add', params: {'SNG_ID': id}); + } + + //Add album to favorites/library + Future addFavoriteAlbum(String id) async { + await callGwApi('album.addFavorite', params: {'ALB_ID': id}); + } + + //Add artist to favorites/library + Future addFavoriteArtist(String id) async { + await callGwApi('artist.addFavorite', params: {'ART_ID': id}); + } + + //Remove artist from favorites/library + Future removeArtist(String id) async { + await callGwApi('artist.deleteFavorite', params: {'ART_ID': id}); + } + + // Mark track as disliked + Future dislikeTrack(String id) async { + await callGwApi('favorite_dislike.add', params: {'ID': id, 'TYPE': 'song'}); + } + + //Add tracks to playlist + Future addToPlaylist(String trackId, String playlistId, + {int offset = -1}) async { + await callGwApi('playlist.addSongs', params: { + 'offset': offset, + 'playlist_id': playlistId, + 'songs': [ + [trackId, 0] + ] + }); + } + + //Remove track from playlist + Future removeFromPlaylist(String trackId, String playlistId) async { + await callGwApi('playlist.deleteSongs', params: { + 'playlist_id': playlistId, + 'songs': [ + [trackId, 0] + ] + }); + } + + //Get users playlists + Future> getPlaylists() async { + Map data = await callGwApi('deezer.pageProfile', + params: {'nb': 100, 'tab': 'playlists', 'user_id': userId}); + return data['results']['TAB']['playlists']['data'] + .map((json) => Playlist.fromPrivateJson(json, library: true)) + .toList(); + } + + //Get favorite trackIds + Future?> getFavoriteTrackIds() async { + Map data = + await callGwApi('user.getAllFeedbacks', params: {'checksums': null}); + final songsData = data['results']?['FAVORITES']?['SONGS']?['data']; + + if (songsData is List) { + return songsData.map((song) => song['SNG_ID'] as String).toList(); + } + return null; + } + + //Get favorite albums + Future> getAlbums() async { + Map data = await callGwApi('deezer.pageProfile', + params: {'nb': 50, 'tab': 'albums', 'user_id': userId}); + List albumList = data['results']['TAB']['albums']['data']; + List albums = albumList + .map((json) => Album.fromPrivateJson(json, library: true)) + .toList(); + return albums; + } + + //Remove album from library + Future removeAlbum(String id) async { + await callGwApi('album.deleteFavorite', params: {'ALB_ID': id}); + } + + //Remove track from favorites + Future removeFavorite(String id) async { + await callGwApi('favorite_song.remove', params: {'SNG_ID': id}); + } + + //Get favorite artists + Future> getArtists() async { + Map data = await callGwApi('deezer.pageProfile', + params: {'nb': 40, 'tab': 'artists', 'user_id': userId}); + return data['results']['TAB']['artists']['data'] + .map((json) => Artist.fromPrivateJson(json, library: true)) + .toList(); + } + + //Get lyrics by track id + Future lyrics(String trackId) async { + // First try to get lyrics from pipe API + Lyrics lyricsFromPipeApi = await lyricsFull(trackId); + + if (lyricsFromPipeApi.errorMessage == null && + lyricsFromPipeApi.isLoaded()) { + return lyricsFromPipeApi; + } + + // Fallback to get lyrics from legacy GW api + Lyrics lyricsFromLegacy = await lyricsLegacy(trackId); + + if (lyricsFromLegacy.errorMessage == null && lyricsFromLegacy.isLoaded()) { + return lyricsFromLegacy; + } + + // No lyrics found, prefer to use pipe api error message + return lyricsFromPipeApi; + } + + //Get lyrics by track id from legacy GW api + Future lyricsLegacy(String trackId) async { + Map data = await callGwApi('song.getLyrics', params: {'sng_id': trackId}); + if (data['error'] != null && data['error'].length > 0) { + return Lyrics.error(data['error']['DATA_ERROR']); + } + return LyricsClassic.fromPrivateJson(data['results']); + } + + //Get lyrics by track id from pipe API + Future lyricsFull(String trackId) async { + // Create lyrics request body with GraphQL query + String queryStringGraphQL = ''' + query SynchronizedTrackLyrics(\$trackId: String!) { + track(trackId: \$trackId) { + id + isExplicit + lyrics { + id + copyright + text + writers + synchronizedLines { + lrcTimestamp + line + milliseconds + duration + } + } + } + }'''; + + /* Alternative query using fragments, used by Deezer web app + String queryStringGraphQL = ''' + query SynchronizedTrackLyrics(\$trackId: String!) { + track(trackId: \$trackId) { + ...SynchronizedTrackLyrics + } + } + fragment SynchronizedTrackLyrics on Track { + id + isExplicit + lyrics { + ...Lyrics + } + } + fragment Lyrics on Lyrics { + id + copyright + text + writers + synchronizedLines { + ...LyricsSynchronizedLines + } + } + fragment LyricsSynchronizedLines on LyricsSynchronizedLine { + lrcTimestamp + line + milliseconds + duration + } + '''; + */ + + Map requestParams = { + 'operationName': 'SynchronizedTrackLyrics', + 'variables': {'trackId': trackId}, + 'query': queryStringGraphQL + }; + Map data = await callPipeApi(params: requestParams); + if (data['errors'] != null && data['errors'].length > 0) { + return Lyrics.error(data['errors']['message']); + } + return LyricsFull.fromPrivateJson(data['data']); + } + + Future smartTrackList(String id) async { + Map data = await callGwApi('deezer.pageSmartTracklist', + params: {'smarttracklist_id': id}); + return SmartTrackList.fromPrivateJson(data['results']['DATA'], + songsJson: data['results']['SONGS']); + } + + Future> flow({String? type}) async { + Map data = await callGwApi('radio.getUserRadio', + params: {'user_id': userId, 'config_id': type}); + return data['results']['data'] + .map((json) => Track.fromPrivateJson(json)) + .toList(); + } + + //Get homepage/music library from deezer + Future homePage() async { + List grid = [ + 'album', + 'artist', + 'channel', + 'flow', + 'playlist', + 'radio', + 'show', + 'smarttracklist', + 'track', + 'user' + ]; + Map data = await callGwApi('page.get', + gatewayInput: jsonEncode({ + 'PAGE': 'home', + 'VERSION': '2.5', + 'SUPPORT': { + /* + "deeplink-list": ["deeplink"], + "list": ["episode"], + "grid-preview-one": grid, + "grid-preview-two": grid, + "slideshow": grid, + "message": ["call_onboarding"], + */ + 'filterable-grid': ['flow'], + 'grid': grid, + 'horizontal-grid': grid, + 'item-highlight': ['radio'], + 'large-card': ['album', 'playlist', 'show', 'video-link'], + 'ads': [] //Nope + }, + 'LANG': settings.deezerLanguage, + 'OPTIONS': [] + })); + return HomePage.fromPrivateJson(data['results']); + } + + //Log song listen to deezer + Future logListen(String trackId) async { + await callGwApi('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 { + List grid = [ + 'album', + 'artist', + 'channel', + 'flow', + 'playlist', + 'radio', + 'show', + 'smarttracklist', + 'track', + 'user' + ]; + Map data = await callGwApi('page.get', + gatewayInput: jsonEncode({ + 'PAGE': target, + 'VERSION': '2.5', + 'SUPPORT': { + /* + "deeplink-list": ["deeplink"], + "list": ["episode"], + "grid-preview-one": grid, + "grid-preview-two": grid, + "slideshow": grid, + "message": ["call_onboarding"], + */ + 'filterable-grid': ['flow'], + 'grid': grid, + 'horizontal-grid': grid, + 'item-highlight': ['radio'], + 'large-card': ['album', 'playlist', 'show', 'video-link'], + 'ads': [] //Nope + }, + 'LANG': settings.deezerLanguage, + 'OPTIONS': [] + })); + return HomePage.fromPrivateJson(data['results']); + } + + //Add playlist to library + Future addPlaylist(String id) async { + await callGwApi('playlist.addFavorite', + params: {'parent_playlist_id': int.parse(id)}); + } + + //Remove playlist from library + Future removePlaylist(String id) async { + await callGwApi('playlist.deleteFavorite', + params: {'playlist_id': int.parse(id)}); + } + + //Delete playlist + Future deletePlaylist(String id) async { + await callGwApi('playlist.delete', params: {'playlist_id': id}); + } + + //Create playlist + //Status 1 - private, 2 - collaborative + Future createPlaylist(String title, + {String description = '', + int status = 1, + List trackIds = const []}) async { + Map data = await callGwApi('playlist.create', params: { + 'title': title, + 'description': description, + 'songs': trackIds + .map((id) => [int.parse(id), trackIds.indexOf(id)]) + .toList(), + 'status': status + }); + //Return playlistId + return data['results'].toString(); + } + + //Get part of discography + Future> discographyPage(String artistId, + {int start = 0, int nb = 50}) async { + Map data = await callGwApi('album.getDiscography', params: { + 'art_id': int.parse(artistId), + 'discography_mode': 'all', + 'nb': nb, + 'start': start, + 'nb_songs': 30 + }); + + return data['results']['data'] + .map((a) => Album.fromPrivateJson(a)) + .toList(); + } + + Future searchSuggestions(String query) async { + Map data = + await callGwApi('search_getSuggestedQueries', params: {'QUERY': query}); + return data['results']['SUGGESTION'].map((s) => s['QUERY']).toList(); + } + + //Get smart radio for artist id + Future> smartRadio(String artistId) async { + Map data = await callGwApi('smart.getSmartRadio', + params: {'art_id': int.parse(artistId)}); + 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 callGwApi('playlist.update', params: { + 'description': description, + 'title': title, + 'playlist_id': int.parse(id), + 'status': status, + 'songs': [] + }); + } + + //Get shuffled library + Future> libraryShuffle({int start = 0}) async { + Map data = await callGwApi('tracklist.getShuffledCollection', + params: {'nb': 50, 'start': start}); + return data['results']['data'] + .map((t) => Track.fromPrivateJson(t)) + .toList(); + } + + //Get similar tracks for track with id [trackId] + Future> playMix(String trackId) async { + Map data = await callGwApi('song.getContextualTrackMix', params: { + 'sng_ids': [trackId] + }); + return data['results']['data'] + .map((t) => Track.fromPrivateJson(t)) + .toList(); + } + + Future> allShowEpisodes(String showId) async { + Map data = await callGwApi('deezer.pageShow', params: { + 'country': settings.deezerCountry, + 'lang': settings.deezerLanguage, + 'nb': 1000, + 'show_id': showId, + 'start': 0, + 'user_id': int.parse(deezerAPI.userId ?? ''), + }); + return data['results']['EPISODES']['data'] + .map((e) => ShowEpisode.fromPrivateJson(e)) + .toList(); + } +} diff --git a/lib/api/deezer_login.dart b/lib/api/deezer_login.dart new file mode 100644 index 0000000..fed5311 --- /dev/null +++ b/lib/api/deezer_login.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import '../utils/cookie_manager.dart'; +import '../utils/env.dart'; + +class DeezerLogin { + static final Map defaultHeaders = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36', + 'X-User-IP': '1.1.1.1', + 'x-deezer-client-ip': '1.1.1.1', + 'Accept': '*/*' + }; + static final cookieManager = CookieManager(); + + //Login with email + static Future getArlByEmailAndPassword(String email, String password) async { + cookieManager.reset(); + // Get initial cookies (sid) from empty getUser call + String url = + 'https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=null'; + cookieManager.updateCookie(await http.get(Uri.parse(url))); + // Fuck the Bearer Token... + //cookieManager.updateCookie(await http.get(Uri.parse(url), headers: {'Authorization': 'Bearer $accessToken'})); + + // Try to get AccessToken by login with email & password, which sets authentication cookies + String? accessToken = await _getAccessToken(email, password); + if (accessToken == null) return ''; + + // Get ARL + Map requestheaders = {...defaultHeaders, ...cookieManager.cookieHeader}; + url = 'https://www.deezer.com/ajax/gw-light.php?method=user.getArl&input=3&api_version=1.0&api_token=null'; + http.Response response = await http.get(Uri.parse(url), headers: requestheaders); + Map data = jsonDecode(response.body); + return data['results']; + } + + static Future _getAccessToken(String email, String password) async { + final clientId = Env.deezerClientId; + final clientSecret = Env.deezerClientSecret; + String? accessToken; + + Map requestheaders = {...defaultHeaders, ...cookieManager.cookieHeader}; + requestheaders.addAll(cookieManager.cookieHeader); + final hashedPassword = md5.convert(utf8.encode(password)).toString(); + final hashedParams = md5.convert(utf8.encode('$clientId$email$hashedPassword$clientSecret')).toString(); + final url = Uri.parse( + 'https://connect.deezer.com/oauth/user_auth.php?app_id=$clientId&login=$email&password=$hashedPassword&hash=$hashedParams'); + + await http.get(url, headers: requestheaders).then((res) { + cookieManager.updateCookie(res); + final responseJson = jsonDecode(res.body); + if (responseJson.containsKey('access_token')) { + accessToken = responseJson['access_token']; + } else if (responseJson.containsKey('error')) { + throw DeezerLoginException(responseJson['error']['type'], responseJson['error']['message']); + } + }).catchError((e) { + Logger.root.severe('Login Error (E): $e'); + if (e is DeezerLoginException) { + // Throw the login exception for custom error dialog + throw e; + } + // All other errors will just use general invalid ARL error dialog + accessToken = null; + }); + + return accessToken; + } +} + +class DeezerLoginException implements Exception { + final String type; + final dynamic message; + + DeezerLoginException(this.type, [this.message]); + + @override + String toString() { + if (message == null) { + return 'DeezerLoginException: $type'; + } else { + return 'DeezerLoginException: $type\nCaused by: $message'; + } + } +} diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart new file mode 100644 index 0000000..05dc54b --- /dev/null +++ b/lib/api/definitions.dart @@ -0,0 +1,1272 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../api/cache.dart'; +import '../translations.i18n.dart'; + +part 'definitions.g.dart'; + +@JsonSerializable() +class Track { + String? id; + String? title; + Album? album; + List? artists; + Duration? duration; + ImageDetails? albumArt; + int? trackNumber; + bool? offline; + LyricsFull? lyrics; + bool? favorite; + int? diskNumber; + bool? explicit; + //Date added to playlist / favorites + int? addedDate; + Track? fallback; + + List? playbackDetails; + List? playbackDetailsFallback; + + Track( + {this.id, + this.title, + this.duration, + this.album, + this.playbackDetails, + this.albumArt, + this.artists, + this.trackNumber, + this.offline, + this.lyrics, + this.favorite, + this.diskNumber, + this.explicit, + this.addedDate, + this.fallback, + this.playbackDetailsFallback}); + + String? get artistString => + artists?.map((art) => art.name ?? '').join(', '); + String? get durationString => + "${duration?.inMinutes}:${duration?.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + + //MediaItem + MediaItem toMediaItem() => MediaItem( + title: title ?? '', + album: album?.title ?? '', + artist: artists?[0].name, + displayTitle: title, + displaySubtitle: artistString, + displayDescription: album?.title, + artUri: Uri.parse(albumArt?.full ?? ''), + duration: duration, + id: id ?? '', + extras: { + 'playbackDetails': jsonEncode(playbackDetails), + 'thumb': albumArt?.thumb, + 'lyrics': jsonEncode(lyrics?.toJson()), + 'albumId': album?.id, + 'artists': + jsonEncode(artists?.map((art) => art.toJson()).toList()), + 'fallbackId': fallback?.id, + 'playbackDetailsFallback': jsonEncode(playbackDetailsFallback), + }); + + factory Track.fromMediaItem(MediaItem mi) { + //Load album, artists & originalId (if track is result of fallback). + //It is stored separately, to save id and other metadata + Album album = Album(title: mi.album); + List artists = [Artist(name: mi.displaySubtitle ?? mi.artist)]; + album.id = mi.extras?['albumId']; + if (mi.extras?['artists'] != null) { + 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(); + } + Track fallback = Track(id: mi.extras?['fallbackId']); + List? playbackDetailsFallback; + if (mi.extras?['playbackDetailsFallback'] != null) { + playbackDetailsFallback = + (jsonDecode(mi.extras?['playbackDetailsFallback']) ?? []) + .map((e) => e.toString()) + .toList(); + } + + return Track( + title: mi.title.isEmpty ? mi.displayTitle : mi.title, + artists: artists, + album: album, + id: mi.id, + albumArt: ImageDetails( + fullUrl: mi.artUri.toString(), thumbUrl: mi.extras?['thumb']), + duration: mi.duration, + playbackDetails: playbackDetails, + lyrics: LyricsFull.fromJson( + jsonDecode(((mi.extras ?? {})['lyrics']) ?? '{}')), + fallback: fallback, + playbackDetailsFallback: playbackDetailsFallback); + } + + //JSON + factory Track.fromPrivateJson(Map json, + {bool favorite = false}) { + String title = json['SNG_TITLE']; + if (json['VERSION'] != null && json['VERSION'] != '') { + title = "${json['SNG_TITLE']} ${json['VERSION']}"; + } + return Track( + id: json['SNG_ID'].toString(), + title: title, + duration: Duration(seconds: int.parse(json['DURATION'])), + albumArt: ImageDetails.fromPrivateString(json['ALB_PICTURE']), + album: Album.fromPrivateJson(json), + artists: (json['ARTISTS'] ?? [json]) + .map((dynamic art) => Artist.fromPrivateJson(art)) + .toList(), + trackNumber: int.parse((json['TRACK_NUMBER'] ?? '0').toString()), + playbackDetails: [ + json['MD5_ORIGIN'], + json['MEDIA_VERSION'], + json['TRACK_TOKEN'] + ], + lyrics: LyricsFull(id: json['LYRICS_ID']?.toString() ?? ''), + favorite: favorite, + diskNumber: int.parse(json['DISK_NUMBER'] ?? '1'), + explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true : false, + addedDate: json['DATE_ADD'], + fallback: (json['FALLBACK'] != null) + ? Track.fromPrivateJson(json['FALLBACK']) + : null, + playbackDetailsFallback: (json['FALLBACK'] != null) + ? [ + json['FALLBACK']['MD5_ORIGIN'], + json['FALLBACK']?['MEDIA_VERSION'], + json['FALLBACK']?['TRACK_TOKEN'] + ] + : null, + ); + } + Map toSQL({off = false}) => { + 'id': id, + 'title': title, + 'album': album?.id, + 'artists': artists?.map((dynamic a) => a.id).join(','), + 'duration': duration?.inSeconds, + 'albumArt': albumArt?.full, + 'trackNumber': trackNumber, + 'offline': off ? 1 : 0, + 'lyrics': jsonEncode(lyrics?.toJson()), + 'favorite': (favorite ?? false) ? 1 : 0, + 'diskNumber': diskNumber, + 'explicit': (explicit ?? false) ? 1 : 0, + 'fallback': fallback?.id, + //'favoriteDate': favoriteDate + }; + factory Track.fromSQL(Map data) => Track( + id: data['trackId'] ?? data['id'], //If loading from downloads table + title: data['title'], + album: Album(id: data['album'], title: ''), + duration: Duration(seconds: data['duration']), + albumArt: ImageDetails(fullUrl: data['albumArt']), + trackNumber: data['trackNumber'], + artists: List.generate(data['artists'].split(',').length, + (i) => Artist(id: data['artists'].split(',')[i])), + offline: (data['offline'] == 1) ? true : false, + lyrics: LyricsFull.fromJson(jsonDecode(data['lyrics'])), + favorite: (data['favorite'] == 1) ? true : false, + diskNumber: data['diskNumber'], + explicit: (data['explicit'] == 1) ? true : false, + fallback: data['fallback'] != null ? Track(id: data['fallback']) : null, + //favoriteDate: data['favoriteDate'] + ); + + factory Track.fromJson(Map json) => _$TrackFromJson(json); + Map toJson() => _$TrackToJson(this); +} + +enum AlbumType { ALBUM, SINGLE, FEATURED } + +@JsonSerializable() +class Album { + String? id; + String? title; + List? artists; + List? tracks; + ImageDetails? art; + int? fans; + bool? offline; //If the album is offline, or just saved in db as metadata + bool? library; + AlbumType? type; + String? releaseDate; + String? favoriteDate; + + Album( + {this.id, + this.title, + this.art, + this.artists, + this.tracks, + this.fans, + this.offline, + this.library, + this.type, + this.releaseDate, + this.favoriteDate}); + + String? get artistString => + artists?.map((art) => art.name ?? '').join(', '); + Duration get duration => + Duration(seconds: tracks!.fold(0, (v, t) => v += t.duration!.inSeconds)); + String get durationString => + "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + String? get fansString => NumberFormat.compact().format(fans); + + //JSON + factory Album.fromPrivateJson(Map json, + {Map songsJson = const {}, bool library = false}) { + AlbumType type = AlbumType.ALBUM; + if (json['TYPE'] != null && json['TYPE'].toString() == '0') { + type = AlbumType.SINGLE; + } + if (json['ROLE_ID'] == 5) type = AlbumType.FEATURED; + + return Album( + id: json['ALB_ID'].toString(), + title: json['ALB_TITLE'], + art: ImageDetails.fromPrivateString(json['ALB_PICTURE']), + artists: (json['ARTISTS'] ?? []) + .map((dynamic art) => Artist.fromPrivateJson(art)) + .toList(), + tracks: (songsJson['data'] ?? []) + .map((dynamic track) => Track.fromPrivateJson(track)) + .toList(), + fans: json['NB_FAN'], + library: library, + type: type, + releaseDate: + json['DIGITAL_RELEASE_DATE'] ?? json['PHYSICAL_RELEASE_DATE'], + favoriteDate: json['DATE_FAVORITE']); + } + Map toSQL({off = false}) => { + 'id': id, + 'title': title, + 'artists': (artists ?? []).map((dynamic a) => a.id).join(','), + 'tracks': (tracks ?? []).map((dynamic t) => t.id).join(','), + 'art': art?.full ?? '', + 'fans': fans, + 'offline': off ? 1 : 0, + 'library': (library ?? false) ? 1 : 0, + 'type': (type != null) ? AlbumType.values.indexOf(type!) : -1, + 'releaseDate': releaseDate, + //'favoriteDate': favoriteDate + }; + factory Album.fromSQL(Map data) => Album( + id: data['id'], + title: data['title'], + artists: List.generate(data['artists'].split(',').length, + (i) => Artist(id: data['artists'].split(',')[i])), + tracks: List.generate(data['tracks'].split(',').length, + (i) => Track(id: data['tracks'].split(',')[i])), + art: ImageDetails(fullUrl: data['art']), + fans: data['fans'], + offline: (data['offline'] == 1) ? true : false, + library: (data['library'] == 1) ? true : false, + type: AlbumType.values[(data['type'] == -1) ? 0 : data['type']], + releaseDate: data['releaseDate'], + //favoriteDate: data['favoriteDate'] + ); + + factory Album.fromJson(Map json) => _$AlbumFromJson(json); + Map toJson() => _$AlbumToJson(this); +} + +enum ArtistHighlightType { ALBUM } + +@JsonSerializable() +class ArtistHighlight { + dynamic data; + ArtistHighlightType? type; + String? title; + + ArtistHighlight({this.data, this.type, this.title}); + + static ArtistHighlight? fromPrivateJson(Map json) { + if (json['ITEM'] == null) return null; + switch (json['TYPE']) { + case 'album': + return ArtistHighlight( + data: Album.fromPrivateJson(json['ITEM']), + type: ArtistHighlightType.ALBUM, + title: json['TITLE']); + } + return null; + } + + //JSON + factory ArtistHighlight.fromJson(Map json) => + _$ArtistHighlightFromJson(json); + Map toJson() => _$ArtistHighlightToJson(this); +} + +@JsonSerializable() +class Artist { + String? id; + String? name; + List albums; + int? albumCount; + List topTracks; + ImageDetails? picture; + int? fans; + bool? offline; + bool? library; + bool? radio; + String? favoriteDate; + ArtistHighlight? highlight; + + Artist( + {this.id, + this.name, + this.albums = const [], + this.albumCount, + this.topTracks = const [], + this.picture, + this.fans, + this.offline, + this.library, + this.radio, + this.favoriteDate, + this.highlight}); + + String get fansString => NumberFormat.compact().format(fans); + + //JSON + factory Artist.fromPrivateJson(Map json, + {Map albumsJson = const {}, + Map topJson = const {}, + Map highlight = const {}, + bool library = false}) { + //Get wether radio is available + bool radio = false; + if (json['SMARTRADIO'] == true || json['SMARTRADIO'] == 1) radio = true; + + return Artist( + id: json['ART_ID'].toString(), + name: json['ART_NAME'], + fans: json['NB_FAN'], + picture: json['ART_PICTURE'] == null + ? null + : ImageDetails.fromPrivateString(json['ART_PICTURE'], + type: 'artist'), + albumCount: albumsJson['total'], + albums: (albumsJson['data'] ?? []) + .map((dynamic data) => Album.fromPrivateJson(data)) + .toList(), + topTracks: (topJson['data'] ?? []) + .map((dynamic data) => Track.fromPrivateJson(data)) + .toList(), + library: library, + radio: radio, + favoriteDate: json['DATE_FAVORITE'], + highlight: ArtistHighlight.fromPrivateJson(highlight)); + } + Map toSQL({off = false}) => { + 'id': id, + 'name': name, + 'albums': albums.map((dynamic a) => a.id).join(','), + 'topTracks': topTracks.map((dynamic t) => t.id).join(','), + 'picture': picture?.full ?? '', + 'fans': fans, + 'albumCount': albumCount ?? albums.length, + 'offline': off ? 1 : 0, + 'library': (library ?? false) ? 1 : 0, + 'radio': (radio ?? false) ? 1 : 0, + //'favoriteDate': favoriteDate + }; + factory Artist.fromSQL(Map data) => Artist( + id: data['id'], + name: data['name'], + topTracks: List.generate(data['topTracks'].split(',').length, + (i) => Track(id: data['topTracks'].split(',')[i])), + albums: List.generate(data['albums'].split(',').length, + (i) => Album(id: data['albums'].split(',')[i], title: '')), + albumCount: data['albumCount'], + picture: ImageDetails(fullUrl: data['picture']), + fans: data['fans'], + offline: (data['offline'] == 1) ? true : false, + library: (data['library'] == 1) ? true : false, + radio: (data['radio'] == 1) ? true : false, + //favoriteDate: data['favoriteDate'] + ); + + factory Artist.fromJson(Map json) => _$ArtistFromJson(json); + Map toJson() => _$ArtistToJson(this); +} + +@JsonSerializable() +class Playlist { + String? id; + String? title; + List? tracks; + ImageDetails? image; + Duration? duration; + int? trackCount; + User? user; + int? fans; + bool? library; + String? description; + + Playlist( + {this.id, + this.title, + this.tracks, + this.image, + this.trackCount, + this.duration, + this.user, + this.fans, + this.library, + this.description}); + + String get durationString => + "${duration?.inHours}:${duration?.inMinutes.remainder(60).toString().padLeft(2, '0')}:${duration?.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + + //JSON + factory Playlist.fromPrivateJson(Map json, + {Map songsJson = const {}, bool library = false}) => + Playlist( + id: json['PLAYLIST_ID'].toString(), + title: json['TITLE'], + trackCount: json['NB_SONG'] ?? songsJson['total'], + image: ImageDetails.fromPrivateString(json['PLAYLIST_PICTURE'], + type: json['PICTURE_TYPE']), + fans: json['NB_FAN'], + duration: Duration(seconds: json['DURATION'] ?? 0), + description: json['DESCRIPTION'], + user: User( + id: json['PARENT_USER_ID'], + name: json['PARENT_USERNAME'] ?? '', + picture: ImageDetails.fromPrivateString( + json['PARENT_USER_PICTURE'] ?? '', + type: 'user')), + tracks: (songsJson['data'] ?? []) + .map((dynamic data) => Track.fromPrivateJson(data)) + .toList(), + library: library); + Map toSQL() => { + 'id': id, + 'title': title, + 'tracks': tracks?.map((dynamic t) => t.id).join(','), + 'image': image?.full, + 'duration': duration?.inSeconds, + 'userId': user?.id, + 'userName': user?.name, + 'fans': fans, + 'description': description, + 'library': (library ?? false) ? 1 : 0 + }; + factory Playlist.fromSQL(data) => Playlist( + id: data['id'], + title: data['title'], + description: data['description'], + tracks: List.generate(data?['tracks']?.split(',')?.length ?? 0, + (i) => Track(id: data?['tracks']?.split(',')[i])), + image: ImageDetails(fullUrl: data['image']), + duration: Duration(seconds: data?['duration'] ?? 0), + user: User(id: data['userId'], name: data['userName']), + fans: data['fans'], + library: (data['library'] == 1) ? true : false); + + factory Playlist.fromJson(Map json) => + _$PlaylistFromJson(json); + Map toJson() => _$PlaylistToJson(this); +} + +@JsonSerializable() +class User { + String? id; + String? name; + ImageDetails? picture; + + User({this.id, this.name, this.picture}); + + //Mostly handled by playlist + + factory User.fromJson(Map json) => _$UserFromJson(json); + Map toJson() => _$UserToJson(this); +} + +@JsonSerializable() +class ImageDetails { + String? fullUrl; + String? thumbUrl; + String? type; + String? imageHash; + + ImageDetails({this.fullUrl, this.thumbUrl, this.type, this.imageHash}); + + //Get full/thumb with fallback + String? get full => fullUrl ?? thumbUrl; + String? get thumb => thumbUrl ?? fullUrl; + + //Get custom sized image + String customUrl(String height, String width, {String quality = '80'}) { + return 'https://e-cdns-images.dzcdn.net/images/$type/$imageHash/${height}x$width-000000-$quality-0-0.jpg'; + } + + //JSON + factory ImageDetails.fromPrivateString(String imageHash, + {String type = 'cover'}) => + ImageDetails( + type: type, + imageHash: imageHash, + fullUrl: + 'https://e-cdns-images.dzcdn.net/images/$type/$imageHash/1000x1000-000000-80-0-0.jpg', + thumbUrl: + 'https://e-cdns-images.dzcdn.net/images/$type/$imageHash/140x140-000000-80-0-0.jpg'); + factory ImageDetails.fromPrivateJson(Map json) => + ImageDetails.fromPrivateString(json['MD5'] ?? json['md5'], + type: json['TYPE'] ?? json['type']); + //ImageDetails.fromPrivateString((json['MD5']?.split('-')?.first) ?? json['md5'], + //type: json['TYPE'] ?? json['type']); + + factory ImageDetails.fromJson(Map json) => + _$ImageDetailsFromJson(json); + Map toJson() => _$ImageDetailsToJson(this); +} + +@JsonSerializable() +class LogoDetails { + String? fullUrl; + String? thumbUrl; + String? type; + String? imageHash; + + LogoDetails({this.fullUrl, this.thumbUrl, this.type, this.imageHash}); + + //Get full/thumb with fallback + String? get full => fullUrl ?? thumbUrl; + String? get thumb => thumbUrl ?? fullUrl; + + //Get custom sized logo + String customUrl(String height, + {String width = '0', String quality = '100'}) { + return 'https://e-cdns-images.dzcdn.net/images/$type/$imageHash/${height}x$width-none-$quality-0-0.png'; + } + + //JSON + factory LogoDetails.fromPrivateString(String imageHash, + {String type = 'misc'}) => + LogoDetails( + type: type, + imageHash: imageHash, + fullUrl: + 'https://e-cdns-images.dzcdn.net/images/$type/$imageHash/208x0-none-100-0-0.png', + thumbUrl: + 'https://e-cdns-images.dzcdn.net/images/$type/$imageHash/52x0-none-100-0-0.png'); + factory LogoDetails.fromPrivateJson(Map json) => + LogoDetails.fromPrivateString( + (json['MD5']?.split('-')?.first) ?? json['md5'], + type: json['TYPE'] ?? json['type']); + + factory LogoDetails.fromJson(Map json) => + _$LogoDetailsFromJson(json); + Map toJson() => _$LogoDetailsToJson(this); +} + +class SearchResults { + List? tracks; + List? albums; + List? artists; + List? playlists; + List? shows; + List? episodes; + + SearchResults( + {this.tracks, + this.albums, + this.artists, + this.playlists, + this.shows, + this.episodes}); + + //Check if no search results + bool get empty { + return ((tracks == null || tracks!.isEmpty) && + (albums == null || albums!.isEmpty) && + (artists == null || artists!.isEmpty) && + (playlists == null || playlists!.isEmpty) && + (shows == null || shows!.isEmpty) && + (episodes == null || episodes!.isEmpty)); + } + + factory SearchResults.fromPrivateJson(Map json) => + SearchResults( + tracks: json['TRACK']['data'] + .map((dynamic data) => Track.fromPrivateJson(data)) + .toList(), + albums: json['ALBUM']['data'] + .map((dynamic data) => Album.fromPrivateJson(data)) + .toList(), + artists: json['ARTIST']['data'] + .map((dynamic data) => Artist.fromPrivateJson(data)) + .toList(), + playlists: json['PLAYLIST']['data'] + .map((dynamic data) => Playlist.fromPrivateJson(data)) + .toList(), + shows: json['SHOW']['data'] + .map((dynamic data) => Show.fromPrivateJson(data)) + .toList(), + episodes: json['EPISODE']['data'] + .map( + (dynamic data) => ShowEpisode.fromPrivateJson(data)) + .toList()); +} + +class Lyrics { + String? id; + String? writers; + List? syncedLyrics; + String? errorMessage; + String? unsyncedLyrics; + bool? isExplicit; + String? copyright; + + Lyrics({ + this.id, + this.writers, + this.syncedLyrics, + this.unsyncedLyrics, + this.errorMessage, + this.isExplicit, + this.copyright, + }); + + static error(String? message) => Lyrics( + id: null, + writers: null, + syncedLyrics: [ + SynchronizedLyric( + offset: const Duration(milliseconds: 0), + text: 'Lyrics unavailable, empty or failed to load!'.i18n, + ), + ], + errorMessage: message, + ); + + bool isLoaded() => + syncedLyrics?.isNotEmpty == true || unsyncedLyrics?.isNotEmpty == true; + + bool isSynced() => + syncedLyrics?.isNotEmpty == true && syncedLyrics!.length > 1; + + bool isUnsynced() => !isSynced() && unsyncedLyrics?.isNotEmpty == true; +} + +@JsonSerializable() +class LyricsClassic extends Lyrics { + LyricsClassic({ + super.id, + super.writers, + super.syncedLyrics, + super.errorMessage, + super.unsyncedLyrics, + }); + + factory LyricsClassic.fromPrivateJson(Map json) { + LyricsClassic l = LyricsClassic( + id: json['LYRICS_ID'], + writers: json['LYRICS_WRITERS'], + syncedLyrics: (json['LYRICS_SYNC_JSON'] ?? []) + .map((l) => SynchronizedLyric.fromPrivateJson(l)) + .toList(), + unsyncedLyrics: json['LYRICS_TEXT'], + ); + l.syncedLyrics?.removeWhere((l) => l.offset == null); + return l; + } + + factory LyricsClassic.fromJson(Map json) => + _$LyricsClassicFromJson(json); + + Map toJson() => _$LyricsClassicToJson(this); +} + +@JsonSerializable() +class LyricsFull extends Lyrics { + LyricsFull({ + super.id, + super.writers, + super.syncedLyrics, + super.errorMessage, + super.unsyncedLyrics, + super.isExplicit, + super.copyright, + }); + + factory LyricsFull.fromPrivateJson(Map json) { + var lyricsJson = json['track']['lyrics'] ?? {}; + + return LyricsFull( + id: lyricsJson['id'], + writers: lyricsJson['writers'], + syncedLyrics: (lyricsJson['synchronizedLines'] ?? []) + .map((l) => + SynchronizedLyric.fromPrivateJson(l as Map)) + .toList(), + unsyncedLyrics: lyricsJson['text'], + isExplicit: json['track']['isExplicit'], + copyright: lyricsJson['copyright'], + ); + } + + factory LyricsFull.fromJson(Map json) => + _$LyricsFullFromJson(json); + + Map toJson() => _$LyricsFullToJson(this); +} + +@JsonSerializable() +class SynchronizedLyric { + Duration? offset; + Duration? duration; + String? text; + String? lrcTimestamp; + + SynchronizedLyric({this.offset, this.duration, this.text, this.lrcTimestamp}); + + //JSON + factory SynchronizedLyric.fromPrivateJson(Map json) { + if (json['milliseconds'] == null || json['line'] == null) { + return SynchronizedLyric(); //Empty lyric + } + return SynchronizedLyric( + offset: + Duration(milliseconds: int.parse(json['milliseconds'].toString())), + duration: + Duration(milliseconds: int.parse(json['duration'].toString())), + text: json['line'], + // lrc_timestamp from classic GW API, lrcTimestamp from pipe API + lrcTimestamp: json['lrcTimestamp'] ?? json['lrc_timestamp']); + } + + factory SynchronizedLyric.fromJson(Map json) => + _$SynchronizedLyricFromJson(json); + Map toJson() => _$SynchronizedLyricToJson(this); +} + +@JsonSerializable() +class QueueSource { + String? id; + String? text; + String? source; + + QueueSource({this.id, this.text, this.source}); + + factory QueueSource.fromJson(Map json) => + _$QueueSourceFromJson(json); + Map toJson() => _$QueueSourceToJson(this); +} + +@JsonSerializable() +class SmartTrackList { + String? id; + String? title; + String? subtitle; + String? description; + int? trackCount; + List? tracks; + ImageDetails? cover; + String? flowType; + + SmartTrackList( + {this.id, + this.title, + this.description, + this.trackCount, + this.tracks, + this.cover, + this.subtitle, + this.flowType}); + + //JSON + factory SmartTrackList.fromPrivateJson(Map json, + {Map songsJson = const {}}) => + SmartTrackList( + id: json['SMARTTRACKLIST_ID'], + title: json['TITLE'], + subtitle: json['SUBTITLE'], + description: json['DESCRIPTION'], + trackCount: json['NB_SONG'] ?? (songsJson['total']), + tracks: (songsJson['data'] ?? []) + .map((t) => Track.fromPrivateJson(t)) + .toList(), + cover: ImageDetails.fromPrivateJson(json['COVER'])); + + factory SmartTrackList.fromJson(Map json) => + _$SmartTrackListFromJson(json); + Map toJson() => _$SmartTrackListToJson(this); +} + +@JsonSerializable() +class HomePage { + List sections; + + HomePage({this.sections = const []}); + + //Save/Load + Future _getPath() async { + Directory d = await getApplicationDocumentsDirectory(); + return p.join(d.path, 'homescreen.json'); + } + + Future exists() async { + String path = await _getPath(); + return await File(path).exists(); + } + + Future save() async { + String path = await _getPath(); + await File(path).writeAsString(jsonEncode(toJson())); + } + + Future load() async { + String path = await _getPath(); + Map data = jsonDecode(await File(path).readAsString()); + return HomePage.fromJson(data); + } + + Future wipe() async { + await File(await _getPath()).delete(); + } + + //JSON + factory HomePage.fromPrivateJson(Map json) { + HomePage hp = HomePage(sections: []); + //Parse every section + for (var s in (json['sections'] ?? [])) { + HomePageSection? section = HomePageSection.fromPrivateJson(s); + if (section != null) hp.sections.add(section); + } + return hp; + } + + factory HomePage.fromJson(Map json) => + _$HomePageFromJson(json); + Map toJson() => _$HomePageToJson(this); +} + +@JsonSerializable() +class HomePageSection { + String? title; + HomePageSectionLayout? layout; + + //For loading more items + String? pagePath; + bool? hasMore; + + @JsonKey(fromJson: _homePageItemFromJson, toJson: _homePageItemToJson) + List? items; + + HomePageSection( + {this.layout, this.items, this.title, this.pagePath, this.hasMore}); + + //JSON + static HomePageSection? fromPrivateJson(Map json) { + HomePageSection hps = HomePageSection( + title: json['title'] ?? '', + items: [], + pagePath: json['target'], + hasMore: json['hasMoreItems'] ?? false); + + String layout = json['layout']; + switch (layout) { + case 'ads': + return null; + case 'horizontal-grid': + hps.layout = HomePageSectionLayout.ROW; + break; + case 'filterable-grid': + hps.layout = HomePageSectionLayout.ROW; + break; + case 'grid': + hps.layout = HomePageSectionLayout.GRID; + break; + default: + return null; + } + + //Parse items + for (var i in (json['items'] ?? [])) { + HomePageItem? hpi = HomePageItem.fromPrivateJson(i); + hps.items?.add(hpi); + } + return hps; + } + + factory HomePageSection.fromJson(Map json) => + _$HomePageSectionFromJson(json); + Map toJson() => _$HomePageSectionToJson(this); + + static _homePageItemFromJson(json) => + json.map((d) => HomePageItem.fromJson(d)).toList(); + static _homePageItemToJson(items) => items.map((i) => i.toJson()).toList(); +} + +class HomePageItem { + HomePageItemType? type; + dynamic value; + + HomePageItem({this.type, this.value}); + + static HomePageItem? fromPrivateJson(Map json) { + String type = json['type']; + switch (type) { + case 'flow': + return HomePageItem( + type: HomePageItemType.FLOW, + value: DeezerFlow.fromPrivateJson(json)); + case 'smarttracklist': + //Smart Track List + return HomePageItem( + type: HomePageItemType.SMARTTRACKLIST, + value: SmartTrackList.fromPrivateJson(json['data'])); + case 'playlist': + return HomePageItem( + type: HomePageItemType.PLAYLIST, + value: Playlist.fromPrivateJson(json['data'])); + case 'artist': + return HomePageItem( + type: HomePageItemType.ARTIST, + value: Artist.fromPrivateJson(json['data'])); + case 'channel': + return HomePageItem( + type: HomePageItemType.CHANNEL, + value: DeezerChannel.fromPrivateJson(json)); + case 'album': + return HomePageItem( + type: HomePageItemType.ALBUM, + value: Album.fromPrivateJson(json['data'])); + case 'show': + return HomePageItem( + type: HomePageItemType.SHOW, + value: Show.fromPrivateJson(json['data'])); + default: + return null; + } + } + + factory HomePageItem.fromJson(Map json) { + String t = json['type']; + switch (t) { + case 'FLOW': + return HomePageItem( + type: HomePageItemType.FLOW, + value: DeezerFlow.fromJson(json['value'])); + case 'SMARTTRACKLIST': + return HomePageItem( + type: HomePageItemType.SMARTTRACKLIST, + value: SmartTrackList.fromJson(json['value'])); + case 'PLAYLIST': + return HomePageItem( + type: HomePageItemType.PLAYLIST, + value: Playlist.fromJson(json['value'])); + case 'ARTIST': + return HomePageItem( + type: HomePageItemType.ARTIST, + value: Artist.fromJson(json['value'])); + case 'CHANNEL': + return HomePageItem( + type: HomePageItemType.CHANNEL, + value: DeezerChannel.fromJson(json['value'])); + case 'ALBUM': + return HomePageItem( + type: HomePageItemType.ALBUM, value: Album.fromJson(json['value'])); + case 'SHOW': + return HomePageItem( + type: HomePageItemType.SHOW, + value: Show.fromPrivateJson(json['value'])); + default: + return HomePageItem(); + } + } + + Map toJson() { + String type = this.type.toString().split('.').last; + return {'type': type, 'value': value.toJson()}; + } +} + +@JsonSerializable() +class DeezerChannel { + String? id; + String? target; + String? title; + String? logo; + @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) + Color? backgroundColor; + ImageDetails? backgroundImage; + LogoDetails? logoImage; + + DeezerChannel( + {this.id, + this.title, + this.backgroundColor, + this.target, + this.backgroundImage, + this.logo, + this.logoImage}); + + factory DeezerChannel.fromPrivateJson(Map json) => + DeezerChannel( + id: json['id'], + title: json['title'], + logo: json['data']['logo'], + backgroundColor: Color(int.parse( + (json['background_color'] ?? '#000000').replaceFirst('#', 'FF'), + radix: 16)), + target: json['target'].replaceFirst('/', ''), + backgroundImage: ((json['pictures']) == null) + ? null + : ImageDetails.fromPrivateJson(json['pictures'][0]), + logoImage: ((json['logo_image']) == null) + ? null + : LogoDetails.fromPrivateJson(json['logo_image'])); + + //JSON + static _colorToJson(Color? c) => c?.value; + static _colorFromJson(int? v) => Color(v ?? Colors.blue.value); + factory DeezerChannel.fromJson(Map json) => + _$DeezerChannelFromJson(json); + Map toJson() => _$DeezerChannelToJson(this); +} + +@JsonSerializable() +class DeezerFlow { + String? id; + String? target; + String? title; + ImageDetails? cover; + + DeezerFlow({this.id, this.title, this.target, this.cover}); + + factory DeezerFlow.fromPrivateJson(Map json) => DeezerFlow( + id: json['id'], + title: json['title'], + cover: ImageDetails.fromPrivateJson(json['pictures'][0]), + target: json['target'].replaceFirst('/', '')); + + //JSON + factory DeezerFlow.fromJson(Map json) => + _$DeezerFlowFromJson(json); + Map toJson() => _$DeezerFlowToJson(this); +} + +enum HomePageItemType { + FLOW, + SMARTTRACKLIST, + PLAYLIST, + ARTIST, + CHANNEL, + ALBUM, + SHOW +} + +enum HomePageSectionLayout { ROW, GRID } + +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; + } +} + +//Sorting +enum SortType { + DEFAULT, + ALPHABETIC, + ARTIST, + ALBUM, + RELEASE_DATE, + POPULARITY, + USER, + TRACK_COUNT, + DATE_ADDED +} + +enum SortSourceTypes { + //Library + TRACKS, + PLAYLISTS, + ALBUMS, + ARTISTS, + + PLAYLIST +} + +@JsonSerializable() +class Sorting { + SortType type; + bool reverse; + + //For preserving sorting + String? id; + SortSourceTypes? sourceType; + + Sorting( + {this.type = SortType.DEFAULT, + this.reverse = false, + this.id, + this.sourceType}); + + //Find index of sorting from cache + static int? index(SortSourceTypes type, {String? id}) { + //Find index + int? index; + if (id != null) { + index = cache.sorts.indexWhere((s) => s.sourceType == type && s.id == id); + } else { + index = cache.sorts.indexWhere((s) => s.sourceType == type); + } + if (index == -1) return null; + return index; + } + + factory Sorting.fromJson(Map json) => + _$SortingFromJson(json); + Map toJson() => _$SortingToJson(this); +} + +@JsonSerializable() +class Show { + String? name; + String? description; + ImageDetails? art; + String? id; + + Show({this.name, this.description, this.art, this.id}); + + //JSON + factory Show.fromPrivateJson(Map json) => Show( + id: json['SHOW_ID'], + name: json['SHOW_NAME'], + art: ImageDetails.fromPrivateString(json['SHOW_ART_MD5'], type: 'talk'), + description: json['SHOW_DESCRIPTION']); + + factory Show.fromJson(Map json) => _$ShowFromJson(json); + Map toJson() => _$ShowToJson(this); +} + +@JsonSerializable() +class ShowEpisode { + String? id; + String? title; + String? description; + String? url; + Duration? duration; + String? publishedDate; + //Might not be fully available + Show? show; + + ShowEpisode( + {this.id, + this.title, + this.description, + this.url, + this.duration, + this.publishedDate, + this.show}); + + String get durationString => + "${duration?.inMinutes}:${duration?.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + + //Generate MediaItem for playback + MediaItem toMediaItem(Show show) { + return MediaItem( + title: title ?? '', + displayTitle: title, + displaySubtitle: show.name, + album: show.name ?? '', + id: id ?? '', + extras: { + 'showUrl': url, + 'show': jsonEncode(show.toJson()), + 'thumb': show.art?.thumb + }, + displayDescription: description, + duration: duration, + artUri: Uri.parse(show.art?.full ?? ''), + ); + } + + factory ShowEpisode.fromMediaItem(MediaItem mi) { + return ShowEpisode( + id: mi.id, + title: mi.title, + description: mi.displayDescription, + url: mi.extras?['showUrl'], + duration: mi.duration, + show: Show.fromJson(jsonDecode(mi.extras?['show'] ?? ''))); + } + + //JSON + factory ShowEpisode.fromPrivateJson(Map json) => + ShowEpisode( + id: json['EPISODE_ID'], + title: json['EPISODE_TITLE'], + description: json['EPISODE_DESCRIPTION'], + url: json['EPISODE_DIRECT_STREAM_URL'], + duration: Duration(seconds: int.parse(json['DURATION'].toString())), + publishedDate: json['EPISODE_PUBLISHED_TIMESTAMP'], + show: Show.fromPrivateJson(json)); + + factory ShowEpisode.fromJson(Map json) => + _$ShowEpisodeFromJson(json); + Map toJson() => _$ShowEpisodeToJson(this); +} + +class StreamQualityInfo { + String? format; + int? size; + String? source; + + StreamQualityInfo({this.format, this.size, this.source}); + + factory StreamQualityInfo.fromJson(Map json) => StreamQualityInfo( + format: json['format'], size: json['size'], source: json['source']); + + int bitrate(Duration duration) { + if ((size ?? 0) == 0) return 0; + int bitrate = (((size! * 8) / 1000) / duration.inSeconds).round(); + //Round to known values + if (bitrate > 122 && bitrate < 134) return 128; + if (bitrate > 315 && bitrate < 325) return 320; + return bitrate; + } +} diff --git a/lib/api/definitions.g.dart b/lib/api/definitions.g.dart new file mode 100644 index 0000000..c96364c --- /dev/null +++ b/lib/api/definitions.g.dart @@ -0,0 +1,499 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'definitions.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Track _$TrackFromJson(Map json) => Track( + id: json['id'] as String?, + title: json['title'] as String?, + duration: json['duration'] == null + ? null + : Duration(microseconds: (json['duration'] as num).toInt()), + album: json['album'] == null + ? null + : Album.fromJson(json['album'] as Map), + playbackDetails: json['playbackDetails'] as List?, + albumArt: json['albumArt'] == null + ? null + : ImageDetails.fromJson(json['albumArt'] as Map), + artists: (json['artists'] as List?) + ?.map((e) => Artist.fromJson(e as Map)) + .toList(), + trackNumber: (json['trackNumber'] as num?)?.toInt(), + offline: json['offline'] as bool?, + lyrics: json['lyrics'] == null + ? null + : LyricsFull.fromJson(json['lyrics'] as Map), + favorite: json['favorite'] as bool?, + diskNumber: (json['diskNumber'] as num?)?.toInt(), + explicit: json['explicit'] as bool?, + addedDate: (json['addedDate'] as num?)?.toInt(), + fallback: json['fallback'] == null + ? null + : Track.fromJson(json['fallback'] as Map), + playbackDetailsFallback: + json['playbackDetailsFallback'] as List?, + ); + +Map _$TrackToJson(Track instance) => { + 'id': instance.id, + 'title': instance.title, + 'album': instance.album, + 'artists': instance.artists, + 'duration': instance.duration?.inMicroseconds, + 'albumArt': instance.albumArt, + 'trackNumber': instance.trackNumber, + 'offline': instance.offline, + 'lyrics': instance.lyrics, + 'favorite': instance.favorite, + 'diskNumber': instance.diskNumber, + 'explicit': instance.explicit, + 'addedDate': instance.addedDate, + 'fallback': instance.fallback, + 'playbackDetails': instance.playbackDetails, + 'playbackDetailsFallback': instance.playbackDetailsFallback, + }; + +Album _$AlbumFromJson(Map json) => Album( + id: json['id'] as String?, + title: json['title'] as String?, + art: json['art'] == null + ? null + : ImageDetails.fromJson(json['art'] as Map), + artists: (json['artists'] as List?) + ?.map((e) => Artist.fromJson(e as Map)) + .toList(), + tracks: (json['tracks'] as List?) + ?.map((e) => Track.fromJson(e as Map)) + .toList(), + fans: (json['fans'] as num?)?.toInt(), + offline: json['offline'] as bool?, + library: json['library'] as bool?, + type: $enumDecodeNullable(_$AlbumTypeEnumMap, json['type']), + releaseDate: json['releaseDate'] as String?, + favoriteDate: json['favoriteDate'] as String?, + ); + +Map _$AlbumToJson(Album instance) => { + 'id': instance.id, + 'title': instance.title, + 'artists': instance.artists, + 'tracks': instance.tracks, + 'art': instance.art, + 'fans': instance.fans, + 'offline': instance.offline, + 'library': instance.library, + 'type': _$AlbumTypeEnumMap[instance.type], + 'releaseDate': instance.releaseDate, + 'favoriteDate': instance.favoriteDate, + }; + +const _$AlbumTypeEnumMap = { + AlbumType.ALBUM: 'ALBUM', + AlbumType.SINGLE: 'SINGLE', + AlbumType.FEATURED: 'FEATURED', +}; + +ArtistHighlight _$ArtistHighlightFromJson(Map json) => + ArtistHighlight( + data: json['data'], + type: $enumDecodeNullable(_$ArtistHighlightTypeEnumMap, json['type']), + title: json['title'] as String?, + ); + +Map _$ArtistHighlightToJson(ArtistHighlight instance) => + { + 'data': instance.data, + 'type': _$ArtistHighlightTypeEnumMap[instance.type], + 'title': instance.title, + }; + +const _$ArtistHighlightTypeEnumMap = { + ArtistHighlightType.ALBUM: 'ALBUM', +}; + +Artist _$ArtistFromJson(Map json) => Artist( + id: json['id'] as String?, + name: json['name'] as String?, + albums: (json['albums'] as List?) + ?.map((e) => Album.fromJson(e as Map)) + .toList() ?? + const [], + albumCount: (json['albumCount'] as num?)?.toInt(), + topTracks: (json['topTracks'] as List?) + ?.map((e) => Track.fromJson(e as Map)) + .toList() ?? + const [], + picture: json['picture'] == null + ? null + : ImageDetails.fromJson(json['picture'] as Map), + fans: (json['fans'] as num?)?.toInt(), + offline: json['offline'] as bool?, + library: json['library'] as bool?, + radio: json['radio'] as bool?, + favoriteDate: json['favoriteDate'] as String?, + highlight: json['highlight'] == null + ? null + : ArtistHighlight.fromJson(json['highlight'] as Map), + ); + +Map _$ArtistToJson(Artist instance) => { + 'id': instance.id, + 'name': instance.name, + 'albums': instance.albums, + 'albumCount': instance.albumCount, + 'topTracks': instance.topTracks, + 'picture': instance.picture, + 'fans': instance.fans, + 'offline': instance.offline, + 'library': instance.library, + 'radio': instance.radio, + 'favoriteDate': instance.favoriteDate, + 'highlight': instance.highlight, + }; + +Playlist _$PlaylistFromJson(Map json) => Playlist( + id: json['id'] as String?, + title: json['title'] as String?, + tracks: (json['tracks'] as List?) + ?.map((e) => Track.fromJson(e as Map)) + .toList(), + image: json['image'] == null + ? null + : ImageDetails.fromJson(json['image'] as Map), + trackCount: (json['trackCount'] as num?)?.toInt(), + duration: json['duration'] == null + ? null + : Duration(microseconds: (json['duration'] as num).toInt()), + user: json['user'] == null + ? null + : User.fromJson(json['user'] as Map), + fans: (json['fans'] as num?)?.toInt(), + library: json['library'] as bool?, + description: json['description'] as String?, + ); + +Map _$PlaylistToJson(Playlist instance) => { + 'id': instance.id, + 'title': instance.title, + 'tracks': instance.tracks, + 'image': instance.image, + 'duration': instance.duration?.inMicroseconds, + 'trackCount': instance.trackCount, + 'user': instance.user, + 'fans': instance.fans, + 'library': instance.library, + 'description': instance.description, + }; + +User _$UserFromJson(Map json) => User( + id: json['id'] as String?, + name: json['name'] as String?, + picture: json['picture'] == null + ? null + : ImageDetails.fromJson(json['picture'] as Map), + ); + +Map _$UserToJson(User instance) => { + 'id': instance.id, + 'name': instance.name, + 'picture': instance.picture, + }; + +ImageDetails _$ImageDetailsFromJson(Map json) => ImageDetails( + fullUrl: json['fullUrl'] as String?, + thumbUrl: json['thumbUrl'] as String?, + type: json['type'] as String?, + imageHash: json['imageHash'] as String?, + ); + +Map _$ImageDetailsToJson(ImageDetails instance) => + { + 'fullUrl': instance.fullUrl, + 'thumbUrl': instance.thumbUrl, + 'type': instance.type, + 'imageHash': instance.imageHash, + }; + +LogoDetails _$LogoDetailsFromJson(Map json) => LogoDetails( + fullUrl: json['fullUrl'] as String?, + thumbUrl: json['thumbUrl'] as String?, + type: json['type'] as String?, + imageHash: json['imageHash'] as String?, + ); + +Map _$LogoDetailsToJson(LogoDetails instance) => + { + 'fullUrl': instance.fullUrl, + 'thumbUrl': instance.thumbUrl, + 'type': instance.type, + 'imageHash': instance.imageHash, + }; + +LyricsClassic _$LyricsClassicFromJson(Map json) => + LyricsClassic( + id: json['id'] as String?, + writers: json['writers'] as String?, + syncedLyrics: (json['syncedLyrics'] as List?) + ?.map((e) => SynchronizedLyric.fromJson(e as Map)) + .toList(), + errorMessage: json['errorMessage'] as String?, + unsyncedLyrics: json['unsyncedLyrics'] as String?, + ) + ..isExplicit = json['isExplicit'] as bool? + ..copyright = json['copyright'] as String?; + +Map _$LyricsClassicToJson(LyricsClassic instance) => + { + 'id': instance.id, + 'writers': instance.writers, + 'syncedLyrics': instance.syncedLyrics, + 'errorMessage': instance.errorMessage, + 'unsyncedLyrics': instance.unsyncedLyrics, + 'isExplicit': instance.isExplicit, + 'copyright': instance.copyright, + }; + +LyricsFull _$LyricsFullFromJson(Map json) => LyricsFull( + id: json['id'] as String?, + writers: json['writers'] as String?, + syncedLyrics: (json['syncedLyrics'] as List?) + ?.map((e) => SynchronizedLyric.fromJson(e as Map)) + .toList(), + errorMessage: json['errorMessage'] as String?, + unsyncedLyrics: json['unsyncedLyrics'] as String?, + isExplicit: json['isExplicit'] as bool?, + copyright: json['copyright'] as String?, + ); + +Map _$LyricsFullToJson(LyricsFull instance) => + { + 'id': instance.id, + 'writers': instance.writers, + 'syncedLyrics': instance.syncedLyrics, + 'errorMessage': instance.errorMessage, + 'unsyncedLyrics': instance.unsyncedLyrics, + 'isExplicit': instance.isExplicit, + 'copyright': instance.copyright, + }; + +SynchronizedLyric _$SynchronizedLyricFromJson(Map json) => + SynchronizedLyric( + offset: json['offset'] == null + ? null + : Duration(microseconds: (json['offset'] as num).toInt()), + duration: json['duration'] == null + ? null + : Duration(microseconds: (json['duration'] as num).toInt()), + text: json['text'] as String?, + lrcTimestamp: json['lrcTimestamp'] as String?, + ); + +Map _$SynchronizedLyricToJson(SynchronizedLyric instance) => + { + 'offset': instance.offset?.inMicroseconds, + 'duration': instance.duration?.inMicroseconds, + 'text': instance.text, + 'lrcTimestamp': instance.lrcTimestamp, + }; + +QueueSource _$QueueSourceFromJson(Map json) => QueueSource( + id: json['id'] as String?, + text: json['text'] as String?, + source: json['source'] as String?, + ); + +Map _$QueueSourceToJson(QueueSource instance) => + { + 'id': instance.id, + 'text': instance.text, + 'source': instance.source, + }; + +SmartTrackList _$SmartTrackListFromJson(Map json) => + SmartTrackList( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + trackCount: (json['trackCount'] as num?)?.toInt(), + tracks: (json['tracks'] as List?) + ?.map((e) => Track.fromJson(e as Map)) + .toList(), + cover: json['cover'] == null + ? null + : ImageDetails.fromJson(json['cover'] as Map), + subtitle: json['subtitle'] as String?, + flowType: json['flowType'] as String?, + ); + +Map _$SmartTrackListToJson(SmartTrackList instance) => + { + 'id': instance.id, + 'title': instance.title, + 'subtitle': instance.subtitle, + 'description': instance.description, + 'trackCount': instance.trackCount, + 'tracks': instance.tracks, + 'cover': instance.cover, + 'flowType': instance.flowType, + }; + +HomePage _$HomePageFromJson(Map json) => HomePage( + sections: (json['sections'] as List?) + ?.map((e) => HomePageSection.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$HomePageToJson(HomePage instance) => { + 'sections': instance.sections, + }; + +HomePageSection _$HomePageSectionFromJson(Map json) => + HomePageSection( + layout: + $enumDecodeNullable(_$HomePageSectionLayoutEnumMap, json['layout']), + items: HomePageSection._homePageItemFromJson(json['items']), + title: json['title'] as String?, + pagePath: json['pagePath'] as String?, + hasMore: json['hasMore'] as bool?, + ); + +Map _$HomePageSectionToJson(HomePageSection instance) => + { + 'title': instance.title, + 'layout': _$HomePageSectionLayoutEnumMap[instance.layout], + 'pagePath': instance.pagePath, + 'hasMore': instance.hasMore, + 'items': HomePageSection._homePageItemToJson(instance.items), + }; + +const _$HomePageSectionLayoutEnumMap = { + HomePageSectionLayout.ROW: 'ROW', + HomePageSectionLayout.GRID: 'GRID', +}; + +DeezerChannel _$DeezerChannelFromJson(Map json) => + DeezerChannel( + id: json['id'] as String?, + title: json['title'] as String?, + backgroundColor: DeezerChannel._colorFromJson( + (json['backgroundColor'] as num?)?.toInt()), + target: json['target'] as String?, + backgroundImage: json['backgroundImage'] == null + ? null + : ImageDetails.fromJson( + json['backgroundImage'] as Map), + logo: json['logo'] as String?, + logoImage: json['logoImage'] == null + ? null + : LogoDetails.fromJson(json['logoImage'] as Map), + ); + +Map _$DeezerChannelToJson(DeezerChannel instance) => + { + 'id': instance.id, + 'target': instance.target, + 'title': instance.title, + 'logo': instance.logo, + 'backgroundColor': DeezerChannel._colorToJson(instance.backgroundColor), + 'backgroundImage': instance.backgroundImage, + 'logoImage': instance.logoImage, + }; + +DeezerFlow _$DeezerFlowFromJson(Map json) => DeezerFlow( + id: json['id'] as String?, + title: json['title'] as String?, + target: json['target'] as String?, + cover: json['cover'] == null + ? null + : ImageDetails.fromJson(json['cover'] as Map), + ); + +Map _$DeezerFlowToJson(DeezerFlow instance) => + { + 'id': instance.id, + 'target': instance.target, + 'title': instance.title, + 'cover': instance.cover, + }; + +Sorting _$SortingFromJson(Map json) => Sorting( + type: $enumDecodeNullable(_$SortTypeEnumMap, json['type']) ?? + SortType.DEFAULT, + reverse: json['reverse'] as bool? ?? false, + id: json['id'] as String?, + sourceType: + $enumDecodeNullable(_$SortSourceTypesEnumMap, json['sourceType']), + ); + +Map _$SortingToJson(Sorting instance) => { + 'type': _$SortTypeEnumMap[instance.type]!, + 'reverse': instance.reverse, + 'id': instance.id, + 'sourceType': _$SortSourceTypesEnumMap[instance.sourceType], + }; + +const _$SortTypeEnumMap = { + SortType.DEFAULT: 'DEFAULT', + SortType.ALPHABETIC: 'ALPHABETIC', + SortType.ARTIST: 'ARTIST', + SortType.ALBUM: 'ALBUM', + SortType.RELEASE_DATE: 'RELEASE_DATE', + SortType.POPULARITY: 'POPULARITY', + SortType.USER: 'USER', + SortType.TRACK_COUNT: 'TRACK_COUNT', + SortType.DATE_ADDED: 'DATE_ADDED', +}; + +const _$SortSourceTypesEnumMap = { + SortSourceTypes.TRACKS: 'TRACKS', + SortSourceTypes.PLAYLISTS: 'PLAYLISTS', + SortSourceTypes.ALBUMS: 'ALBUMS', + SortSourceTypes.ARTISTS: 'ARTISTS', + SortSourceTypes.PLAYLIST: 'PLAYLIST', +}; + +Show _$ShowFromJson(Map json) => Show( + name: json['name'] as String?, + description: json['description'] as String?, + art: json['art'] == null + ? null + : ImageDetails.fromJson(json['art'] as Map), + id: json['id'] as String?, + ); + +Map _$ShowToJson(Show instance) => { + 'name': instance.name, + 'description': instance.description, + 'art': instance.art, + 'id': instance.id, + }; + +ShowEpisode _$ShowEpisodeFromJson(Map json) => ShowEpisode( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + url: json['url'] as String?, + duration: json['duration'] == null + ? null + : Duration(microseconds: (json['duration'] as num).toInt()), + publishedDate: json['publishedDate'] as String?, + show: json['show'] == null + ? null + : Show.fromJson(json['show'] as Map), + ); + +Map _$ShowEpisodeToJson(ShowEpisode instance) => + { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'url': instance.url, + 'duration': instance.duration?.inMicroseconds, + 'publishedDate': instance.publishedDate, + 'show': instance.show, + }; diff --git a/lib/api/download.dart b/lib/api/download.dart new file mode 100644 index 0000000..402cf8e --- /dev/null +++ b/lib/api/download.dart @@ -0,0 +1,816 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:disk_space_plus/disk_space_plus.dart'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../utils/navigator_keys.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../utils/file_utils.dart'; + +DownloadManager downloadManager = DownloadManager(); + +class DownloadManager { + //Platform channels + static const MethodChannel platform = MethodChannel('r.r.refreezer/native'); + static const EventChannel eventChannel = + EventChannel('r.r.refreezer/downloads'); + + bool running = false; + int queueSize = 0; + + StreamController serviceEvents = StreamController.broadcast(); + String? offlinePath; + Database? db; + + //Start/Resume downloads + Future start() async { + //Returns whether service is bound or not, the delay is really shitty/hacky way, until i find a real solution + await updateServiceSettings(); + await platform.invokeMethod('start'); + } + + //Stop/Pause downloads + Future stop() async { + await platform.invokeMethod('stop'); + } + + Future init() async { + //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(dbPath, version: 1, + onCreate: (Database db, int version) async { + Batch b = db.batch(); + //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, fallback 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(); + }); + + //Create offline directory + var directory = await getExternalStorageDirectory(); + if (directory != null) { + offlinePath = p.join(directory.path, 'offline/'); + await Directory(offlinePath!).create(recursive: true); + } + + //Update settings + await updateServiceSettings(); + + //Listen to state change event + eventChannel.receiveBroadcastStream().listen((e) { + if (e['action'] == 'onStateChange') { + running = e['running']; + queueSize = e['queueSize']; + } + + //Forward + serviceEvents.add(e); + }); + + await platform.invokeMethod('loadDownloads'); + } + + //Get all downloads from db + Future> getDownloads() async { + List raw = await platform.invokeMethod('getDownloads'); + return raw.map((d) => Download.fromJson(d)).toList(); + } + + //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) as Map, + conflictAlgorithm: ConflictAlgorithm.ignore); + //Artists + if (track.artists != null) { + for (Artist a in track.artists!) { + batch.insert('Artists', a.toSQL(off: false), + conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + return batch; + } + + //Quality selector for custom quality + Future qualitySelect() async { + AudioQuality? quality; + await showModalBottomSheet( + context: mainNavigatorKey.currentContext!, + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 2), + child: Text( + 'Quality'.i18n, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 20.0), + ), + ), + ListTile( + title: const Text('MP3 128kbps'), + onTap: () { + quality = AudioQuality.MP3_128; + mainNavigatorKey.currentState?.pop(); + }, + ), + ListTile( + title: const Text('MP3 320kbps'), + onTap: () { + quality = AudioQuality.MP3_320; + mainNavigatorKey.currentState?.pop(); + }, + ), + ListTile( + title: const Text('FLAC'), + onTap: () { + quality = AudioQuality.FLAC; + mainNavigatorKey.currentState?.pop(); + }, + ) + ], + ); + }); + return quality; + } + + Future openStoragePermissionSettingsDialog() async { + Completer completer = Completer(); + + await showDialog( + context: mainNavigatorKey.currentContext!, + builder: (context) { + return AlertDialog( + title: Text('Storage Permission Required'.i18n), + content: Text( + 'Storage permission is required to download content.\nPlease open system settings and grant storage permission to ReFreezer.' + .i18n), + actions: [ + TextButton( + child: Text('Cancel'.i18n), + onPressed: () { + Navigator.of(context).pop(); + completer.complete(false); + }, + ), + TextButton( + child: Text('Open system settings'.i18n), + onPressed: () { + Navigator.of(context).pop(); + completer.complete(true); + }, + ), + ], + ); + }, + ); + + return completer.future; + } + + Future openSAFPermissionDialog() async { + Completer completer = Completer(); + + await showDialog( + context: mainNavigatorKey.currentContext!, + builder: (context) { + return AlertDialog( + title: Text('External Storage Access Required'.i18n), + content: Text( + 'To download files to the external storage, please grant access to the SD card or USB root directory in the following screen.' + .i18n), + actions: [ + TextButton( + child: Text('Cancel'.i18n), + onPressed: () { + Navigator.of(context).pop(); + completer.complete(false); + }, + ), + TextButton( + child: Text('Continue'.i18n), + onPressed: () { + Navigator.of(context).pop(); + completer.complete(true); + }, + ), + ], + ); + }, + ); + + return completer.future; + } + + Future addOfflineTrack(Track track, + {private = true, isSingleton = false}) async { + //Permission + if (!private && !(await checkPermission())) return false; + + //Ask for quality + AudioQuality? quality; + if (!private && settings.downloadQuality == AudioQuality.ASK) { + quality = await qualitySelect(); + if (quality == null) return false; + } + + //Fetch track if missing meta + if (track.artists == null || track.artists!.isEmpty) { + track = await deezerAPI.track(track.id!); + } + + //Add to DB + if (private) { + Batch b = db!.batch(); + b = await _addTrackToDB(b, track, true); + await b.commit(); + + //Cache art + DefaultCacheManager().getSingleFile(track.albumArt?.thumb ?? ''); + DefaultCacheManager().getSingleFile(track.albumArt?.full ?? ''); + } + + //Get path + String path = _generatePath(track, private, isSingleton: isSingleton); + await platform.invokeMethod('addDownloads', [ + await Download.jsonFromTrack(track, path, + private: private, quality: quality) + ]); + await start(); + return true; + } + + Future addOfflineAlbum(Album album, {private = true}) async { + //Permission + if (!private && !(await checkPermission())) return; + + //Ask for quality + AudioQuality? quality; + if (!private && settings.downloadQuality == AudioQuality.ASK) { + quality = await qualitySelect(); + if (quality == null) return false; + } + + //Get from API if no tracks + if (album.tracks == null || album.tracks!.isEmpty) { + album = await deezerAPI.album(album.id ?? ''); + } + + //Add to DB + if (private) { + //Cache art + DefaultCacheManager().getSingleFile(album.art?.thumb ?? ''); + DefaultCacheManager().getSingleFile(album.art?.full ?? ''); + + 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, quality: quality)); + } + await platform.invokeMethod('addDownloads', out); + await start(); + } + + Future addOfflinePlaylist(Playlist playlist, + {private = true, AudioQuality? quality}) async { + //Permission + if (!private && !(await checkPermission())) return; + + //Ask for quality + if (!private && + settings.downloadQuality == AudioQuality.ASK && + quality == null) { + quality = await qualitySelect(); + if (quality == null) return false; + } + + //Get tracks if missing + if ((playlist.tracks == null) || + (playlist.tracks?.length ?? 0) < (playlist.trackCount ?? 0)) { + 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); + //Cache art + DefaultCacheManager().getSingleFile(t.albumArt?.thumb ?? ''); + DefaultCacheManager().getSingleFile(t.albumArt?.full ?? ''); + } + await b.commit(); + } + + //Generate downloads + List out = []; + for (int i = 0; i < (playlist.tracks?.length ?? 0); i++) { + Track t = playlist.tracks![i]; + out.add(await Download.jsonFromTrack( + t, + _generatePath( + t, + private, + playlistName: playlist.title, + playlistTrackNumber: i, + ), + private: private, + quality: quality)); + } + await platform.invokeMethod('addDownloads', out); + await start(); + } + + //Get track and meta from offline DB + Future getOfflineTrack(String id, + {Album? album, List? artists}) async { + List tracks = await db!.query('Tracks', where: 'id == ?', whereArgs: [id]); + if (tracks.isEmpty) 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.isNotEmpty) 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.isNotEmpty) newArtists.add(Artist.fromSQL(rawArtist[0])); + } + if (newArtists.isNotEmpty) 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) { + var offlineTrack = await getOfflineTrack(rawTrack['id']); + if (offlineTrack != null) out.add(offlineTrack); + } + return out; + } + + //Get all offline available tracks + Future> allOfflineTracks() async { + List rawTracks = + await db!.query('Tracks', where: 'offline == 1', columns: ['id']); + List out = []; + //Load track meta individually + for (Map rawTrack in rawTracks) { + var offlineTrack = await getOfflineTrack(rawTrack['id']); + if (offlineTrack != null) out.add(offlineTrack); + } + 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) { + var offlineAlbum = await getOfflineAlbum(rawAlbum['id']); + if (offlineAlbum != null) out.add(offlineAlbum); + } + return out; + } + + //Get offline album with meta + Future getOfflineAlbum(String id) async { + List rawAlbums = + await db!.query('Albums', where: 'id == ?', whereArgs: [id]); + if (rawAlbums.isEmpty) return null; + Album album = Album.fromSQL(rawAlbums[0]); + + List tracks = []; + //Load tracks + for (int i = 0; i < (album.tracks?.length ?? 0); i++) { + var offlineTrack = await getOfflineTrack(album.tracks![i].id!); + if (offlineTrack != null) tracks.add(offlineTrack); + } + album.tracks = tracks; + //Load artists + List artists = []; + for (int i = 0; i < (album.artists?.length ?? 0); i++) { + artists.add((await getOfflineArtist(album.artists![i].id ?? '')) ?? + album.artists![i]); + } + album.artists = artists; + + return album; + } + + //Get offline artist METADATA, not tracks + Future getOfflineArtist(String id) async { + List rawArtists = + await db!.query('Artists', where: 'id == ?', whereArgs: [id]); + if (rawArtists.isEmpty) return null; + return Artist.fromSQL(rawArtists[0]); + } + + //Get all offline playlists + Future> getOfflinePlaylists() async { + List rawPlaylists = await db!.query('Playlists', columns: ['id']); + List out = []; + for (Map rawPlaylist in rawPlaylists) { + var offlinePlayList = await getOfflinePlaylist(rawPlaylist['id']); + if (offlinePlayList != null) out.add(offlinePlayList); + } + return out; + } + + //Get offline playlist + Future getOfflinePlaylist(String id) async { + List rawPlaylists = + await db!.query('Playlists', where: 'id == ?', whereArgs: [id]); + if (rawPlaylists.isEmpty) return null; + + Playlist playlist = Playlist.fromSQL(rawPlaylists[0]); + //Load tracks + List tracks = []; + if (playlist.tracks != null) { + for (Track t in playlist.tracks!) { + var offlineTrack = await getOfflineTrack(t.id!); + if (offlineTrack != null) tracks.add(offlineTrack); + } + } + playlist.tracks = tracks; + return playlist; + } + + Future removeOfflineTracks(List 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.isNotEmpty) { + //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 && + rawTrack[0]['favorite'] == 0) { + //Safe to remove + await db!.delete('Tracks', where: 'id == ?', whereArgs: [t.id]); + } else { + await db!.update('Tracks', {'offline': 0}, + where: 'id == ?', whereArgs: [t.id]); + } + } + + //Remove file + try { + File(p.join(offlinePath!, t.id)).delete(); + } catch (e) { + Logger.root.severe('Error deleting offline track: ${t.id}', e); + } + } + } + + Future removeOfflineAlbum(String id) async { + //Get album + List rawAlbums = + await db!.query('Albums', where: 'id == ?', whereArgs: [id]); + if (rawAlbums.isEmpty) 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 { + //Fetch playlist + List rawPlaylists = + await db!.query('Playlists', where: 'id == ?', whereArgs: [id]); + if (rawPlaylists.isEmpty) return; + Playlist playlist = Playlist.fromSQL(rawPlaylists[0]); + //Remove playlist + await db!.delete('Playlists', where: 'id == ?', whereArgs: [id]); + await removeOfflineTracks(playlist.tracks!); + } + + //Check if album, track or playlist is offline + Future checkOffline( + {Album? album, Track? track, Playlist? playlist}) async { + if (track != null) { + //Track + List res = await db!.query('Tracks', + where: 'id == ? AND offline == 1', whereArgs: [track.id]); + if (res.isEmpty) return false; + return true; + } else if (album != null) { + //Album + List res = await db!.query('Albums', + where: 'id == ? AND offline == 1', whereArgs: [album.id]); + if (res.isEmpty) return false; + return true; + } else if (playlist != null) { + //Playlist + List res = await db! + .query('Playlists', where: 'id == ?', whereArgs: [playlist.id]); + if (res.isEmpty) 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) { + var offlineTrack = await getOfflineTrack(trackData['id']); + if (offlineTrack != null) results.tracks!.add(offlineTrack); + } + //Albums + List albumsData = await db!.rawQuery( + 'SELECT (id) FROM Albums WHERE offline == 1 AND title like "%$query%"'); + for (Map rawAlbum in albumsData) { + var offlineAlbum = await getOfflineAlbum(rawAlbum['id']); + if (offlineAlbum != null) results.albums!.add(offlineAlbum); + } + //Playlists + List playlists = await db! + .rawQuery('SELECT * FROM Playlists WHERE title like "%$query%"'); + for (Map playlist in playlists) { + var offlinePlaylist = await getOfflinePlaylist(playlist['id']); + if (offlinePlaylist != null) results.playlists!.add(offlinePlaylist); + } + return results; + } + + //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, + bool isSingleton = false}) { + 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, '%albumArtist%'); + + //Album folder / with disk number + if (settings.albumFolder) { + if (settings.albumDiscFolder) { + path = p.join(path, + '%album%' + ' - Disk ' + (track.diskNumber ?? 1).toString()); + } else { + path = p.join(path, '%album%'); + } + } + //Final path + path = p.join(path, + isSingleton ? settings.singletonFilename : 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 + Future> getStats() async { + //Get offline counts + int? trackCount = Sqflite.firstIntValue( + (await db!.rawQuery('SELECT COUNT(*) FROM Tracks WHERE offline == 1'))); + int? albumCount = Sqflite.firstIntValue( + (await db!.rawQuery('SELECT COUNT(*) FROM Albums WHERE offline == 1'))); + int? playlistCount = Sqflite.firstIntValue( + (await db!.rawQuery('SELECT COUNT(*) FROM Playlists'))); + //Free space + double diskSpace = await DiskSpacePlus.getFreeDiskSpace ?? 0; + //Used space + List offlineStat = + await Directory(offlinePath!).list().toList(); + int offlineSize = 0; + for (var fs in offlineStat) { + offlineSize += (await fs.stat()).size; + } + //Return in list, //TODO: Make into class in future + return ([ + trackCount.toString(), + albumCount.toString(), + playlistCount.toString(), + filesize(offlineSize), + filesize((diskSpace * 1000000).floor()) + ]); + } + + //Send settings to download service + Future updateServiceSettings() async { + await platform.invokeMethod( + 'updateSettings', settings.getServiceSettings()); + } + + //Check storage permission + Future checkPermission() async { + //if (await FileUtils.checkManageStoragePermission( + // openStoragePermissionSettingsDialog)) { + if (await FileUtils.checkExternalStoragePermissions( + openStoragePermissionSettingsDialog, + )) { + return true; + } else { + Fluttertoast.showToast( + msg: 'Storage permission denied!'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + return false; + } + } + + //Remove download from queue/finished + Future removeDownload(int id) async { + await platform.invokeMethod('removeDownload', {'id': id}); + } + + //Restart failed downloads + Future retryDownloads() async { + //Permission + if (!(await checkPermission())) return; + await platform.invokeMethod('retryDownloads'); + } + + //Delete downloads by state + Future removeDownloads(DownloadState state) async { + await platform.invokeMethod( + 'removeDownloads', {'state': DownloadState.values.indexOf(state)}); + } +} + +class Download { + int? id; + String? path; + bool? private; + String? trackId; + String? streamTrackId; + String? trackToken; + String? md5origin; + String? mediaVersion; + String? title; + String? image; + int? quality; + //Dynamic + DownloadState? state; + int? received; + int? filesize; + + Download( + {this.id, + this.path, + this.private, + this.trackId, + this.streamTrackId, + this.trackToken, + this.md5origin, + this.mediaVersion, + this.title, + this.image, + this.state, + this.received, + this.filesize, + this.quality}); + + //Get progress between 0 - 1 + double get progress { + return ((received?.toDouble() ?? 0.0) / (filesize?.toDouble() ?? 1.0)) + .toDouble(); + } + + 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']); + } + + //Change values from "update json" + void updateFromJson(Map data) { + quality = data['quality']; + received = data['received'] ?? 0; + state = DownloadState.values[data['state']]; + //Prevent null division later + filesize = ((data['filesize'] ?? 0) <= 0) ? 1 : (data['filesize'] ?? 1); + } + + //Track to download JSON for service + static Future jsonFromTrack(Track t, String path, + {private = true, AudioQuality? quality}) async { + //Get download info + if (t.playbackDetails?.isEmpty ?? true) { + t = await deezerAPI.track(t.id ?? ''); + } + + // Select playbackDetails for audio stream + List? playbackDetails = + t.playbackDetailsFallback?.isNotEmpty == true + ? t.playbackDetailsFallback + : t.playbackDetails; + + return { + 'private': private, + 'trackId': t.id, + 'streamTrackId': t.fallback?.id ?? t.id, + 'md5origin': playbackDetails?[0], + 'mediaVersion': playbackDetails?[1], + 'trackToken': playbackDetails?[2], + 'quality': private + ? settings.getQualityInt(settings.offlineQuality) + : settings.getQualityInt((quality ?? settings.downloadQuality)), + 'title': t.title, + 'path': path, + 'image': t.albumArt?.thumb + }; + } +} + +//Has to be same order as in java +enum DownloadState { NONE, DOWNLOADING, POST, DONE, DEEZER_ERROR, ERROR } diff --git a/lib/api/importer.dart b/lib/api/importer.dart new file mode 100644 index 0000000..cd9315e --- /dev/null +++ b/lib/api/importer.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../api/download.dart'; + +Importer importer = Importer(); + +class Importer { + //Options + bool download = false; + + late String title; + late String description; + late List tracks; + late String playlistId; + Playlist? playlist; + + bool done = false; + bool busy = false; + + late StreamController _streamController; + + Stream get updateStream => _streamController.stream; + int get ok => tracks.fold(0, (v, t) => (t.state == TrackImportState.OK) ? v + 1 : v); + int get error => tracks.fold(0, (v, t) => (t.state == TrackImportState.ERROR) ? v + 1 : v); + + Importer(); + + //Start importing wrapper + Future start(String title, String? description, List tracks) async { + //Save variables + playlist = null; + this.title = title; + this.description = description ?? ''; + this.tracks = tracks.map((t) { + t.state = TrackImportState.NONE; + return t; + }).toList(); + + //Create playlist + playlistId = await deezerAPI.createPlaylist(title, description: this.description); + + busy = true; + done = false; + _streamController = StreamController.broadcast(); + _start(); + } + + //Start importer + Future _start() async { + for (int i = 0; i < tracks.length; i++) { + try { + String? id = await _searchTrack(tracks[i]); + //Not found + if (id == null) { + tracks[i].state = TrackImportState.ERROR; + _streamController.add(tracks[i]); + continue; + } + //Add to playlist + await deezerAPI.addToPlaylist(id, playlistId.toString()); + tracks[i].state = TrackImportState.OK; + } catch (_) { + //Error occurred, mark as error + tracks[i].state = TrackImportState.ERROR; + } + _streamController.add(tracks[i]); + } + //Get full playlist + playlist = await deezerAPI.playlist(playlistId, nb: 10000); + playlist?.library = true; + + //Download + if (download) { + await downloadManager.addOfflinePlaylist(playlist!, private: false); + } + + //Mark as done + done = true; + busy = false; + //To update UI + _streamController.add(null); + _streamController.close(); + } + + //Find track on Deezer servers + Future _searchTrack(ImporterTrack track) async { + //Try by ISRC + if (track.isrc?.length == 12) { + Map deezer = await deezerAPI.callPublicApi('track/isrc:' + track.isrc.toString()); + if (deezer['id'] != null) { + return deezer['id'].toString(); + } + } + + //Search + String cleanedTitle = track.title.trim().toLowerCase().replaceAll('-', '').replaceAll('&', '').replaceAll('+', ''); + SearchResults results = await deezerAPI.search('${track.artists[0]} $cleanedTitle'); + for (Track t in results.tracks ?? []) { + //Match title + if (_cleanMatching(t.title ?? '') == _cleanMatching(track.title)) { + if (t.artists != null) { + //Match artist + if (_matchArtists(track.artists, t.artists!.map((a) => a.name.toString()).toList())) { + return t.id; + } + } + } + } + + return null; + } + + //Clean title for matching + String _cleanMatching(String t) { + return t + .toLowerCase() + .replaceAll(',', '') + .replaceAll('-', '') + .replaceAll(' ', '') + .replaceAll('&', '') + .replaceAll('+', '') + .replaceAll('/', ''); + } + + String _cleanArtist(String a) { + return a.toLowerCase().replaceAll(' ', '').replaceAll(',', ''); + } + + //Match at least 1 artist + bool _matchArtists(List a, List b) { + //Clean + List a0 = a.map(_cleanArtist).toList(); + List b0 = b.map(_cleanArtist).toList(); + + for (String artist in a0) { + if (b0.contains(artist)) { + return true; + } + } + return false; + } +} + +class ImporterTrack { + String title; + List artists; + String? isrc; + TrackImportState state; + + ImporterTrack(this.title, this.artists, {this.isrc, this.state = TrackImportState.NONE}); +} + +enum TrackImportState { NONE, ERROR, OK } + +extension TrackImportStateExtension on TrackImportState { + Widget get icon { + switch (this) { + case TrackImportState.ERROR: + return const Icon( + Icons.error, + color: Colors.red, + ); + case TrackImportState.OK: + return const Icon(Icons.done, color: Colors.green); + default: + return const SizedBox(width: 0, height: 0); + } + } +} diff --git a/lib/api/spotify.dart b/lib/api/spotify.dart new file mode 100644 index 0000000..fd117a8 --- /dev/null +++ b/lib/api/spotify.dart @@ -0,0 +1,210 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart'; +import 'package:http/http.dart' as http; +import 'package:spotify/spotify.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../api/deezer.dart'; +import '../api/importer.dart'; +import '../settings.dart'; + +class SpotifyScrapper { + //Parse spotify URL to URI (spotify:track:1234) + static String? parseUrl(String url) { + Uri uri = Uri.parse(url); + if (uri.pathSegments.length > 3) return null; //Invalid URL + if (uri.pathSegments.length == 3) return 'spotify:${uri.pathSegments[1]}:${uri.pathSegments[2]}'; + if (uri.pathSegments.length == 2) return 'spotify:${uri.pathSegments[0]}:${uri.pathSegments[1]}'; + return null; + } + + //Get spotify embed url from uri + static String getEmbedUrl(String uri) => 'https://embed.spotify.com/?uri=$uri'; + + //https://link.tospotify.com/ or https://spotify.app.link/ + static Future resolveLinkUrl(String url) async { + http.Response response = await http.get(Uri.parse(url)); + Match? match = RegExp(r'window\.top\.location = validate\("(.+)"\);').firstMatch(response.body); + return match?.group(1); + } + + static Future resolveUrl(String url) async { + if (url.contains('link.tospotify') || url.contains('spotify.app.link')) { + return parseUrl(await resolveLinkUrl(url)); + } + return parseUrl(url); + } + + //Extract JSON data form spotify embed page + static Future getEmbedData(String url) async { + //Fetch + http.Response response = await http.get(Uri.parse(url)); + //Parse + dom.Document document = parse(response.body); + dom.Element? element = document.getElementById('resource'); + + //Some are URL encoded + try { + return jsonDecode(element?.innerHtml ?? ''); + } catch (e) { + return jsonDecode(Uri.decodeComponent(element?.innerHtml ?? '')); + } + } + + static Future playlist(String uri) async { + //Load data + String url = getEmbedUrl(uri); + Map data = await getEmbedData(url); + //Parse + SpotifyPlaylist playlist = SpotifyPlaylist.fromJson(data); + return playlist; + } + + //Get Deezer track ID from Spotify URI + static Future convertTrack(String uri) async { + Map data = await getEmbedData(getEmbedUrl(uri)); + SpotifyTrack track = SpotifyTrack.fromJson(data); + Map deezer = await deezerAPI.callPublicApi('track/isrc:' + track.isrc.toString()); + return deezer['id'].toString(); + } + + //Get Deezer album ID by UPC + static Future convertAlbum(String uri) async { + Map data = await getEmbedData(getEmbedUrl(uri)); + SpotifyAlbum album = SpotifyAlbum.fromJson(data); + Map deezer = await deezerAPI.callPublicApi('album/upc:' + album.upc.toString()); + return deezer['id'].toString(); + } +} + +class SpotifyTrack { + String? title; + List? artists; + String? isrc; + + SpotifyTrack({this.title, this.artists, this.isrc}); + + //JSON + factory SpotifyTrack.fromJson(Map json) => SpotifyTrack( + title: json['name'], + artists: json['artists'].map((a) => a['name'].toString()).toList(), + isrc: json['external_ids']['isrc']); + + //Convert track to importer track + ImporterTrack toImporter() { + return ImporterTrack(title.toString(), artists ?? [], isrc: isrc); + } +} + +class SpotifyPlaylist { + String? name; + String? description; + List? tracks; + String? image; + + SpotifyPlaylist({this.name, this.description, this.tracks, this.image}); + + //JSON + factory SpotifyPlaylist.fromJson(Map json) => SpotifyPlaylist( + name: json['name'], + description: json['description'], + image: (json['images'].length > 0) ? json['images'][0]['url'] : null, + tracks: json['tracks']['items'].map((j) => SpotifyTrack.fromJson(j['track'])).toList()); + + //Convert to importer tracks + List toImporter() { + return tracks?.map((t) => t.toImporter()).toList() ?? []; + } +} + +class SpotifyAlbum { + String? upc; + + SpotifyAlbum({this.upc}); + + //JSON + factory SpotifyAlbum.fromJson(Map json) => SpotifyAlbum(upc: json['external_ids']['upc']); +} + +class SpotifyAPIWrapper { + HttpServer? _server; + late SpotifyApi spotify; + late User me; + + //Try authorize with saved credentials + Future trySaved() async { + if (kDebugMode) { + print(settings.spotifyCredentials); + } + if (settings.spotifyClientSecret == null) return false; + final credentials = SpotifyApiCredentials(settings.spotifyClientId, settings.spotifyClientSecret, + accessToken: settings.spotifyCredentials?.accessToken, + refreshToken: settings.spotifyCredentials?.refreshToken, + scopes: settings.spotifyCredentials?.scopes, + expiration: settings.spotifyCredentials?.expiration); + spotify = SpotifyApi(credentials); + me = await spotify.me.get(); + await _save(); + return true; + } + + Future authorize(String clientId, String clientSecret) async { + //Spotify + SpotifyApiCredentials credentials = SpotifyApiCredentials(clientId, clientSecret); + spotify = SpotifyApi(credentials); + //Create server + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 42069); + String? responseUri; + //Get URL + final grant = SpotifyApi.authorizationCodeGrant(credentials); + const redirectUri = 'http://localhost:42069'; + final scopes = ['user-read-private', 'playlist-read-private', 'playlist-read-collaborative', 'user-library-read']; + final authUri = grant.getAuthorizationUrl(Uri.parse(redirectUri), scopes: scopes); + launchUrl(authUri); + //Wait for code + await for (HttpRequest request in _server!) { + //Exit window + request.response.headers.set('Content-Type', 'text/html; charset=UTF-8'); + request.response.write( + '

You can close this page and go back to Freezer.

'); + request.response.close(); + //Get token + if (request.uri.queryParameters['code'] != null) { + _server!.close(); + _server = null; + responseUri = request.uri.toString(); + break; + } + } + //Create spotify + spotify = SpotifyApi.fromAuthCodeGrant(grant, responseUri!); + me = await spotify.me.get(); + + //Save + await _save(); + } + + Future _save() async { + //Save credentials + final spotifyCredentials = await spotify.getCredentials(); + final saveCredentials = SpotifyCredentialsSave( + accessToken: spotifyCredentials.accessToken, + refreshToken: spotifyCredentials.refreshToken, + scopes: spotifyCredentials.scopes, + expiration: spotifyCredentials.expiration); + settings.spotifyClientSecret = spotifyCredentials.clientId; + settings.spotifyClientSecret = spotifyCredentials.clientSecret; + settings.spotifyCredentials = saveCredentials; + await settings.save(); + } + + //Cancel authorization + void cancelAuthorize() { + _server?.close(force: true); + _server = null; + } +} diff --git a/lib/fonts/refreezer_icons.dart b/lib/fonts/refreezer_icons.dart new file mode 100644 index 0000000..c45bf0a --- /dev/null +++ b/lib/fonts/refreezer_icons.dart @@ -0,0 +1,27 @@ +/// Flutter icons ReFreezerIcons +/// Copyright (C) 2024 by original authors @ fluttericon.com, fontello.com +/// This font was generated by FlutterIcon.com, which is derived from Fontello. +/// +/// To use this font, place it in your fonts/ directory and include the +/// following in your pubspec.yaml +/// +/// flutter: +/// fonts: +/// - family: ReFreezerIcons +/// fonts: +/// - asset: fonts/ReFreezerIcons.ttf +/// +/// +/// +library refreezer_icons; + +import 'package:flutter/widgets.dart'; + +class ReFreezerIcons { + ReFreezerIcons._(); + + static const _kFontFam = 'ReFreezerIcons'; + static const String? _kFontPkg = null; + + static const IconData lyrics_mic = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); +} diff --git a/lib/languages/crowdin.dart b/lib/languages/crowdin.dart new file mode 100644 index 0000000..8d19721 --- /dev/null +++ b/lib/languages/crowdin.dart @@ -0,0 +1,11597 @@ +const crowdin = { + 'ar_ar': { + 'Home': 'القائمة الرئيسية', + 'Search': 'بحث', + 'Library': 'المكتبة', + "Offline mode, can't play flow or smart track lists.": + 'وضع خارج الشبكة, لا تستطيع تشغيل اغاني من قوائم ديزر فلو', + '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': 'أدخل الرمز المميز (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": + 'باستخدام هذا التطبيق ، أنت لا توافق على شروط خدمة ديزر', + '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': 'اختر ثيم', + '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': + 'أرسال سجلات الاستماع إلى ديزر ، قم بتمكينها لميزات مثل فلو لتعمل بشكل صحيح (ينصح تفعيلها)', + '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.': + 'انسخ ملف الرابط الرمز المميز لاستخدامه في تطبيقات أخرى.', + '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 ReFreezer to apply!': + 'تم تغيير اللغة، الرجاء إعادة تشغيل فريزر لتطبيق!', + 'Importing...': 'جار الاستيراد...', + 'Radio': 'راديو', + 'Flow': 'فلو', + 'Track is not available on Deezer!': 'الأغنية غير متوفرة على ديزر!', + 'Failed to download track! Please restart.': + 'فشل تنزيل الأغنية! الرجاء إعادة التشغيل.', + 'Storage permission denied!': 'رفض إذن التخزين!', + 'Failed': 'فشل', + 'Queued': 'في قائمة الانتظار', + 'External': 'تخزين', + 'Restart failed downloads': 'أعد استئناف التنزيلات الفاشلة', + 'Clear failed': 'فشل المسح', + 'Download Settings': 'إعدادات التنزيل', + 'Create folder for playlist': 'إنشاء ملف لقائمة التشغيل', + 'Download .LRC lyrics': 'تنزيل ملف كلمات الاغنية .LRC', + 'Proxy': 'بروكسي', + 'Not set': 'غير محدد', + 'Search or paste URL': 'ابحث أو الصق رابط', + 'History': 'تاريخ السماع', + 'Download threads': 'عدد التنزيلات في نفس الوقت', + 'Lyrics unavailable, empty or failed to load!': + 'الكلمات غير متوفرة، فارغة أو فشل تنزيلها!', + 'About': 'حول البرنامج', + 'Telegram Channel': 'قناة التلكرام', + 'To get latest releases': 'لتنزيل اخر اصدارات البرنامج', + 'Official chat': 'الدردشة الرسمية', + 'Telegram Group': 'مجموعة التلكرام', + 'Huge thanks to all the contributors! <3': 'شكرا جزيلا لجميع المساهمين! <3', + 'Edit playlist': 'تعديل قائمة التشغيل', + 'Update': 'تحديث', + 'Playlist updated!': 'تم تحديث قائمة التشغيل!', + 'Downloads added!': 'تم إضافة التنزيلات!', + 'Save cover file for every track': 'حفظ صورة الالبوم لكل اغنية', + 'Download Log': 'سجل التنزيل', + 'Repository': 'مستودع الكود', + 'Source code, report issues there.': 'كود المصدر ، ابلغ عن المشاكل هنا.', + 'Use system theme': 'استخدم ثيم النظام', + 'Light': 'ابيض', + 'Popularity': 'الشعبية', + 'User': 'المستخدم', + 'Track count': 'عدد الاغاني', + "If you want to use custom directory naming - use '/' as directory separator.": + "إذا كنت تريد استخدام تسمية مخصصة، استخدم '/' كفاصل بين المسار.", + 'Share': 'مشاركة', + 'Save album cover': 'حفظ غلاف الألبوم', + 'Warning': 'تحذير', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'استخدام الكثير من التنزيلات في نفس الوقت على الأجهزة القديمة / الضعيفة قد يسبب مشاكل!', + 'Create .nomedia files': 'إنشاء ملف .nomedia', + 'To prevent gallery being filled with album art': + 'لمنع ملء المعرض بصور الألبوم', + 'Sleep timer': 'مؤقت النوم', + 'Minutes:': 'دقائق:', + 'Hours:': 'ساعات:', + 'Cancel current timer': 'إلغاء المؤقت الحالي', + 'Current timer ends at': 'المؤقت الحالي ينتهي عند', + 'Smart track list': 'قائمة الأغاني الذكية', + 'Shuffle': 'خلط عشوائي', + 'Library shuffle': 'خلط المكتبة', + 'Ignore interruptions': 'تجاهل الانقطاعات', + 'Requires app restart to apply!': 'يتطلب إعادة التشغيل التطبيق!', + 'Ask before downloading': 'السؤال قبل التنزيل', + 'Search history': 'تاريخ البحث', + 'Clear search history': 'إزالة تاريخ البحث', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'تسجيل الدخول لتفعيل التسجيل.', + 'Login to LastFM': 'تسجيل الدخول في LastFM', + 'Username': 'إسم المستخدم', + 'Password': 'كلمة السر', + 'Login': 'تسجيل الدخول', + 'Authorization error!': 'خطأ في التصريح!', + 'Logged out!': 'تم تسجيل الخروج!', + 'Lyrics': 'كلمات الأغنية', + 'Player gradient background': 'خلفية المشغل المتدرجة', + 'Updates': 'التحديثات', + 'You are running latest version!': 'انت الان تشغل احدث اصدار للبرنامج!', + 'New update available!': 'تحديث جديد متاح!', + 'Current version: ': 'الإصدار الحالي: ', + 'Unsupported platform!': 'منصة غير مدعومة!', + 'Freezer Updates': 'تحديثات فريزر', + 'Update to latest version in the settings.': + 'تحديث لأحدث إصدار في الإعدادات.', + 'Release date': 'تاريخ الاصدار', + 'Shows': 'العروض', + 'Charts': 'الجداول', + 'Browse': 'تصفح', + 'Quick access': 'الوصول السريع', + 'Play mix': 'تشغيل المزيج', + 'Share show': 'مشاركة العرض', + 'Date added': 'تاريخ الإضافة', + 'Discord': 'دسكورد', + 'Official Discord server': 'سيرفر ديسكورد الرسمي', + 'Restart of app is required to properly log out!': + 'إعادة تشغيل التطبيق مطلوب لتسجيل الخروج بشكل صحيح!', + 'Artist separator': 'فاصل الفنانين', + 'Singleton naming': 'تسمية المفرد', + 'Keep the screen on': 'إبق الشاشة مفعلة', + 'Wakelock enabled!': 'تم تفعيل قفل التنبيه!', + 'Wakelock disabled!': 'تم تعطيل قفل التنبيه!', + 'Show all shows': 'إظهار جميع التطبيقات', + 'Episodes': 'الحلقات', + 'Show all episodes': 'إظهار جميع الحلقات', + 'Album cover resolution': 'دقة غلاف الألبوم', + "WARNING: Resolutions above 1200 aren't officially supported": + 'تحذير: القرارات التي تتجاوز 1200 غير مدعومة رسمياً', + 'Album removed from library!': 'تم إزالة الألبوم من المكتبة!', + 'Remove offline': 'إزالة دون اتصال', + 'Playlist removed from library!': 'تم إزالة قائمة التشغيل من المكتبة!', + 'Blur player background': 'تشويش خلفية المشغل', + 'Might have impact on performance': 'قد يكون له تأثير على الأداء', + 'Font': 'الخط', + 'Select font': 'إختيار خط', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'هذا التطبيق غير مصنوع لدعم العديد من الخطوط، يمكن تخريب الواجهة. استخدمه على مسؤوليتك الخاصة!', + 'Enable equalizer': 'تمكين المعادل', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'قد تفعل بعض تطبيقات المعادل للعمل. يتطلب إعادة تشغيل فريزر', + 'Visualizer': 'المؤثرات المرئية', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'إظهار المؤثرات المرئية على صفحة الكلمات. تحذير: يتطلب إذن الميكروفون!', + 'Tags': 'التاغات ( الوسوم)', + 'Album': 'الألبوم', + 'Track number': 'رقم الأغنية', + 'Disc number': 'رقم القرص', + 'Album artist': 'فنان الألبوم', + 'Date/Year': 'التاريخ/السنة', + 'Label': 'المُسمّى', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'مجموع الاغاني', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'كلمات الأغاني الغير متزامنة', + 'Genre': 'الصنف', + 'Contributors': 'المساهمون', + 'Album art': 'صورة الألبوم', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'ديزر غير متوفر في بلدك، قد لا يعمل فريزر بشكل صحيح. الرجاء استخدام VPN', + 'Deezer is unavailable': 'ديزر غير متوفر', + 'Continue': 'استمرار', + 'Email Login': 'تسجيل الدخول بالبريد الإلكتروني', + 'Email': 'البريد الإلكتروني', + 'Missing email or password!': 'البريد الإلكتروني أو كلمة المرور مفقودة!', + 'Error logging in using email, please check your credentials.\nError:': + 'خطأ في تسجيل الدخول باستخدام البريد الإلكتروني، الرجاء التحقق من البيانات الخاصة بك.\nخطأ:', + 'Error logging in!': 'خطأ في تسجيل الدخول!', + 'Change display mode': 'تبديل وضع العرض', + 'Enable high refresh rates': 'تمكين معدلات تحديث عالية (إطارات في الثانية)', + 'Display mode': 'وضع العرض', + 'Spotify v1': 'سبوتيفاي v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'استيراد قوائم تشغيل Spotify حتى 100 مسار بدون أي تسجيل دخول.', + 'Download imported tracks': 'تحميل الملفات التي تم استيرادها', + 'Start import': 'بدء الاستيراد', + 'Spotify v2': 'سبوتيفاي v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'استيراد أي قائمة تشغيل Spotify ، واستيراد من مكتبة Spotify الخاصة بها. يتطلب حساب مجاني.', + 'Spotify Importer v2': 'مستورد سبوتيفاي v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'يتطلب هذا المستورد معرف عميل Spotify و سر العميل. للحصول عليهم:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. انتقل إلى: developer.spotify.com/dashboard وقم بإنشاء تطبيق.', + 'Open in Browser': 'فتح في المتصفح', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. في التطبيق قمت بإنشائه للتو انتقل إلى الإعدادات، وقم بتعيين رابط إعادة التوجيه إلى: ', + 'Copy the Redirect URL': 'نسخ رابط إعادة التوجيه', + 'Client ID': 'معرف العميل', + 'Client Secret': 'المفتاح السري للعميل', + 'Authorize': 'ترخيص', + 'Logged in as: ': 'مسجل دخول كـ: ', + 'Import playlists by URL': 'استيراد قوائم التشغيل بواسطة الرابط', + 'URL': 'الرابط', + 'Options': 'خيارات', + 'Invalid/Unsupported URL': 'رابط غير صالح/غير مدعوم', + 'Please wait...': 'الرجاء الإنتظار...', + 'Login using email': 'تسجيل الدخول باستخدام البريد الإلكتروني', + 'Track removed from offline!': 'تم إزالة الاغنية من وضع عدم الاتصال!', + 'Removed album from offline!': 'تم إزالة الألبوم من وضع عدم الاتصال!', + 'Playlist removed from offline!': + 'تم إزالة قائمة التشغيل من وضع عدم الاتصال!', + 'Repeat': 'تكرار', + 'Repeat one': 'كرر مرة واحدة', + 'Repeat off': 'التكرار مُعطّل', + 'Love': 'أحب', + 'Unlove': 'ازالة الحب', + 'Dislike': 'لم يعجبني', + 'Close': 'إغلاق', + 'Sort playlist': 'ترتيب قائمة التشغيل', + 'Sort ascending': 'ترتيب تصاعدي', + 'Sort descending': 'ترتيب تنازلي', + 'Stop': 'إيقاف', + 'Start': 'بدء', + 'Clear all': 'تنظيف الكل', + 'Play previous': 'تشغيل السابقة', + 'Play': 'تشغيل', + 'Pause': 'إيقاف', + 'Remove': 'حذف', + 'Seekbar': 'شريط التقديم', + 'Singles': 'الأغاني المفردة', + 'Featured': 'المميز', + 'Fans': 'المعجبون', + 'Duration': 'المدة', + 'Sort': 'ترتيب', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'قد انتهت صلاحية الـ ARL الخاص بك، حاول تسجيل الخروج وتسجيل الدخول مرة أخرى باستخدام ARL أو بطريقة المتصفح.' + }, + 'ast_es': { + 'Home': 'Aniciu', + 'Search': 'Buscar', + 'Library': 'Biblioteca', + "Offline mode, can't play flow or smart track lists.": + 'Mou ensin conexón. Nun vas poder reproducir el Flow nin les llistes intelixentes.', + 'Added to library': 'Amestóse a la biblioteca', + 'Download': 'Baxar', + 'Disk': 'Discu', + 'Offline': 'Ensin conexón', + 'Top Tracks': 'Pistes destacaes', + 'Show more tracks': 'Amosar más pistes', + 'Top': 'Lo destacao de:', + 'Top Albums': 'Álbumes destacaos', + 'Show all albums': 'Amosar tolos álbumes', + 'Discography': 'Discografía', + 'Default': 'Por defeutu', + 'Reverse': 'Invertir', + 'Alphabetic': 'Alfabéticamente', + 'Artist': 'Artista', + 'Post processing...': 'Posprocesando…', + 'Done': 'Fecho', + 'Delete': 'Desaniciar', + 'Are you sure you want to delete this download?': + '¿De xuru que quies desaniciar esta descarga?', + 'Cancel': 'Encaboxar', + 'Downloads': 'Descargues', + 'Clear queue': 'Llimpiar la cola', + "This won't delete currently downloading item": + "Esto nun va desaniciar l'elementu en descarga", + 'Are you sure you want to delete all queued downloads?': + '¿De xuru que quies desaniciar toles descargues en cola?', + 'Clear downloads history': "Llimpiar l'historial de descargues", + 'WARNING: This will only clear non-offline (external downloads)': + 'ALVERTENCIA: Esto namás va llimpiar les descargues esternes', + 'Please check your connection and try again later...': + 'Comprueba la conexón y volvi tentalo dempués…', + 'Show more': 'Amosar más', + 'Importer': 'Importador', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Anguaño namás se sofita Spotify con una llende de 100 pistes', + 'Due to API limitations': "Pola mor de torgues de l'API", + 'Enter your playlist link below': + "Introduz embaxo l'enllaz de la to llista", + 'Error loading URL!': '¡Hebo un fallu al cargar la URL!', + 'Convert': 'Convertir', + 'Download only': 'Baxar namás', + 'Downloading is currently stopped, click here to resume.': + 'La descarga ta parada, calca equí pa siguir con ella.', + 'Tracks': 'Pistes', + 'Albums': 'Álbumes', + 'Artists': 'Artistes', + 'Playlists': 'Llistes', + 'Import': 'Importación', + 'Import playlists from Spotify': 'Importa llistes de Spotify', + 'Statistics': 'Estadístiques', + 'Offline tracks': 'Pistes nel mou ensin conexón', + 'Offline albums': 'Álbumes nel mou ensin conexón', + 'Offline playlists': 'Llistes nel mou ensin conexón', + 'Offline size': 'Tamañu del mou ensin conexón', + 'Free space': 'Espaciu llibre', + 'Loved tracks': 'Pistes favorites', + 'Favorites': 'Favoritos', + 'All offline tracks': 'Toles pistes nel mou ensin conexón', + 'Create new playlist': 'Crear una llista', + 'Cannot create playlists in offline mode': + 'Nun pues crear llistes nel mou ensin conexón', + 'Error': 'Fallu', + 'Error logging in! Please check your token and internet connection and try again.': + "¡Hebo un fallu p'aniciar sesión! Comprueba'l pase y la conexón a internet y volvi tentalo, por favor.", + 'Dismiss': 'Escartar', + 'Welcome to': 'Afáyate en', + 'Please login using your Deezer account.': + 'Anicia sesión con una cuenta de Deezer, por favor.', + 'Login using browser': 'Aniciar sesión con un restolador', + 'Login using token': 'Aniciar sesión con un pase', + 'Enter ARL': "Introducción d'una ARL", + 'Token (ARL)': 'Pase (ARL)', + 'Save': 'Guardar', + "If you don't have account, you can register on deezer.com for free.": + 'Si nun tienes una cuenta, pues rexistrate de baldre en deezer.com.', + 'Open in browser': 'Abrir nun restolador', + "By using this app, you don't agree with the Deezer ToS": + "Col usu d'esta aplicación refugues los Términos del Serviciu de Deezer", + 'Play next': 'Reproducir darréu', + 'Add to queue': 'Amestar a la cola', + 'Add track to favorites': 'Amestar la pista a Favoritos', + 'Add to playlist': 'Amestar a una llista', + 'Select playlist': "Esbilla d'una llista", + 'Track added to': 'La pista amestóse a', + 'Remove from playlist': 'Quitar de la llista', + 'Track removed from': 'Quitóse la pista de:', + 'Remove favorite': 'Quitar de Favoritos', + 'Track removed from library': 'Quitóse la pista de la biblioteca', + 'Go to': 'Dir a', + 'Make offline': 'Convertir al mou ensin conexón', + 'Add to library': 'Amestar a la biblioteca', + 'Remove album': "Quitar l'álbum", + 'Album removed': "Quitóse l'álbum", + 'Remove from favorites': 'Quitar de Favoritos', + 'Artist removed from library': "Quitóse l'artista de la biblioteca", + 'Add to favorites': 'Amestar a Favoritos', + 'Remove from library': 'Quitar de la biblioteca', + 'Add playlist to library': 'Amestar la llista a la biblioteca', + 'Added playlist to library': 'Amestóse la llista a la biblioteca', + 'Make playlist offline': 'Facer que la llista seya del mou ensin conexón', + 'Download playlist': 'Baxar la llista', + 'Create playlist': "Creación d'una llista", + 'Title': 'Títulu', + 'Description': 'Descripción', + 'Private': 'Privada', + 'Collaborative': 'En comuña', + 'Create': 'Crear', + 'Playlist created!': '¡Creóse la llista!', + 'Playing from:': 'Reproduciendo dende:', + 'Queue': 'Cola', + 'Offline search': 'Busca ensin conexón', + 'Search Results': 'Resultaos de la busca', + 'No results!': '¡Nun hay resultaos!', + 'Show all tracks': 'Amosar toles pistes', + 'Show all playlists': 'Amosar toles llistes', + 'Settings': 'Axustes', + 'General': 'Xeneral', + 'Appearance': 'Aspeutu', + 'Quality': 'Calidá', + 'Deezer': 'Deezer', + 'Theme': 'Estilu', + 'Currently': 'Anguaño', + 'Select theme': "Esbilla d'un estilu", + 'Dark': 'Escuridá', + 'Black (AMOLED)': 'Prietu', + 'Deezer (Dark)': 'Deezer (Escuridá)', + 'Primary color': 'Color primariu', + 'Selected color': 'El color esbilláu', + 'Use album art primary color': 'Usar el color primariu de les portaes', + 'Warning: might be buggy': 'Alvertencia: Podría fallar', + 'Mobile streaming': 'Tresmisión pela rede móvil', + 'Wifi streaming': 'Tresmisión pela Wi-Fi', + 'External downloads': 'Descargues esternes', + 'Content language': 'Llingua del conteníu', + 'Not app language, used in headers. Now': + "Nun ye la llingua de l'aplicación, úsase nes testeres. Agora", + 'Select language': "Esbilla d'una llingua", + 'Content country': 'País del conteníu', + 'Country used in headers. Now': 'El país usáu nes testeres. Agora', + 'Log tracks': 'Rexistru de pistes', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + "Unvia los rexistros d'escucha a Deezer. Activa esti axuste pa que carauterístiques como Flow funcionen correutamente", + 'Offline mode': 'Mou ensin conexón', + 'Will be overwritten on start.': 'Va sobrescribise nel aniciu.', + 'Error logging in, check your internet connections.': + 'Hebo un fallu al aniciar sesión, comprueba la conexón a internet.', + 'Logging in...': 'Aniciando sesión…', + 'Download path': 'Camín de les descargues', + 'Downloads naming': 'Nome de les descargues', + 'Downloaded tracks filename': 'Nome del ficheru de les pistes baxaes', + 'Valid variables are': 'Les variables válides son', + 'Reset': 'Reafitar', + 'Clear': 'Llimpiar', + 'Create folders for artist': 'Crear carpetes pa los artistes', + 'Create folders for albums': 'Crear carpetes pa los álbumes', + 'Separate albums by discs': 'Separtar los álbumes polos discos', + 'Overwrite already downloaded files': 'Sobrescribir los ficheros yá baxaos', + 'Copy ARL': "Copiar l'ARL", + 'Copy userToken/ARL Cookie for use in other apps.': + "Copia la cookie userToken/ARL pa usala n'otres aplicaciones.", + 'Copied': 'Copióse', + 'Log out': 'Zarrar sesión', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + "Por la mor d'un incompatibilidá del plugin, l'aniciu de sesión col restolador nun ta disponible ensin reaniciar l'aplicación.", + '(ARL ONLY) Continue': '(NAMÁS ARL) Siguir', + 'Log out & Exit': 'Zarrar sesión y colar', + 'Pick-a-Path': "Escoyeta d'un camín", + 'Select storage': "Esbilla d'un almacenamientu", + 'Go up': 'Xubir', + 'Permission denied': "Negóse'l permisu", + 'Language': 'Llingua', + 'Language changed, please restart ReFreezer to apply!': + "La llingua camudó, reanicia ReFreezer p'aplicar los cambeos.", + 'Importing...': 'Importando…', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + '¡La pista nun ta disponible en Deezer!', + 'Failed to download track! Please restart.': + '¡Hebo un fallu al baxar la pista! Reanicia, por favor.', + 'Storage permission denied!': "¡Negóse'l permisu al almacenamientu!", + 'Failed': 'Falló', + 'Queued': 'Na cola', + 'External': 'Almacenamientu', + 'Restart failed downloads': 'Reaniciar les descargues fallíes', + 'Clear failed': 'Llimpiar lo fallío', + 'Download Settings': 'Axustes de descarga', + 'Create folder for playlist': 'Crear una carpeta pa les llistes', + 'Download .LRC lyrics': 'Baxar la lletra en .LRC', + 'Proxy': 'Proxy', + 'Not set': "Nun s'afitó", + 'Search or paste URL': 'Busca o apiega una URL', + 'History': 'Historial', + 'Download threads': 'Descargues simultánees', + 'Lyrics unavailable, empty or failed to load!': + '¡Nun hai lletra, nun ta disponible o falló la so carga!', + 'About': 'Tocante a', + 'Telegram Channel': 'Canal de Telegram', + 'To get latest releases': 'Pa consiguir los últimos anovamientos', + 'Official chat': 'Charra oficial', + 'Telegram Group': 'Grupu de Telegram', + 'Huge thanks to all the contributors! <3': + '¡Munches gracies a toles persones que collaboraron! <3', + 'Edit playlist': 'Editar la llista', + 'Update': 'Anovar', + 'Playlist updated!': '¡Anovóse la llista!', + 'Downloads added!': '¡Amestáronse les descargues!', + 'Save cover file for every track': 'Guardar la portada de cada pista', + 'Download Log': 'Rexistru de descargues', + 'Repository': 'Depósitu', + 'Source code, report issues there.': + "Consigui'l códigu fonte ya informa de problemes ehí.", + 'Use system theme': "Usar l'estilu del sistema", + 'Light': 'Claridá', + 'Popularity': 'Popularidá', + 'User': 'Usuariu', + 'Track count': 'Númberu de pistes', + "If you want to use custom directory naming - use '/' as directory separator.": + 'Si quies usar un nome personalizáu pa direutorios, usa «/» como separtador de direutorios.', + 'Share': 'Compartir', + 'Save album cover': 'Guardar la portada de los álbumes', + 'Warning': 'Alvertencia', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + "¡L'usu de munches descargues simultánees en preseos vieyos/poco potentes pue causar casques!", + 'Create .nomedia files': 'Crear ficheros .nomedia', + 'To prevent gallery being filled with album art': + "Pa evitar que la galería s'enllene de portaes d'álbumes", + 'Sleep timer': 'Temporizador', + 'Minutes:': 'Minutos:', + 'Hours:': 'Hores:', + 'Cancel current timer': 'Anular el temporizador', + 'Current timer ends at': 'El temporizador acaba en', + 'Smart track list': 'Llista intelixente', + 'Shuffle': 'Al debalu', + 'Library shuffle': 'Biblioteca al debalu', + 'Ignore interruptions': 'Inorar les interrupciones', + 'Requires app restart to apply!': + "¡Rique'l reaniciu de l'aplicación p'aplicar los cambeos!", + 'Ask before downloading': 'Entruga enantes de baxar', + 'Search history': 'Historial de busques', + 'Clear search history': "Llimpiar l'historial de busques", + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': + "Anicia sesión p'activar la sincronización de pistes.", + 'Login to LastFM': 'Aniciu de sesión en LastFM', + 'Username': "Nome d'usuariu", + 'Password': 'Contraseña', + 'Login': 'Aniciar sesión', + 'Authorization error!': "¡Fallu de l'autorización!", + 'Logged out!': '¡Zarresti sesión!', + 'Lyrics': 'Lletra', + 'Player gradient background': 'Dilíu del reproductor en segundu planu', + 'Updates': 'Anovamientos', + 'You are running latest version!': '¡Tas executando la última versión!', + 'New update available!': '¡Hai un anovamientu disponible!', + 'Current version: ': 'Versión actual: ', + 'Unsupported platform!': '¡La plataforma nun ta sofitada!', + 'Freezer Updates': 'Anovamientos de Freezer', + 'Update to latest version in the settings.': + 'Anueva a la última versión nos axustes.', + 'Release date': 'Data de llanzamientu', + 'Shows': 'Programes', + 'Charts': 'Charts', + 'Browse': 'Restolar', + 'Quick access': 'Accesu rápidu', + 'Play mix': 'Reproducir un mecíu', + 'Share show': 'Compartir el programa', + 'Date added': "Data d'amiestu", + 'Discord': 'Discord', + 'Official Discord server': 'Sirvidor de Discord oficial', + 'Restart of app is required to properly log out!': + "¡Ríquese reaniciar l'aplicación aniciar sesión afayadizamente!", + 'Artist separator': "Separtador d'artistes", + 'Singleton naming': 'Nome pa pistes úniques', + 'Keep the screen on': 'Caltener la pantalla encesa', + 'Wakelock enabled!': "¡Activóse l'esconsueñu calteníu!", + 'Wakelock disabled!': "¡Desactivóse l'esconsueñu calteníu!", + 'Show all shows': 'Amosar tolos programes', + 'Episodes': 'Episodios', + 'Show all episodes': 'Amosar tolos episodios', + 'Album cover resolution': 'Resolución de les portaes de los álbumes', + "WARNING: Resolutions above 1200 aren't officially supported": + 'ALVERTENCIA: Les resoluciones penriba de 1200 nun tán sofitaes oficialmente', + 'Album removed from library!': "¡Quitóse l'álbum de la biblioteca!", + 'Remove offline': 'Quitar del mou ensin conexón', + 'Playlist removed from library!': '¡Quitóse la llista de la biblioteca!', + 'Blur player background': 'Desenfocar el fondu del reproductor', + 'Might have impact on performance': 'Podría afeutar al rindimientu', + 'Font': 'Fonte', + 'Select font': "Esbilla d'una fonte", + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "Esta aplicación nun ta fecha pa sofitar munches fontes, puen romper el diseñu. ¡Úsalo baxo'l to riesgu!", + 'Enable equalizer': "Activar l'ecualizador", + 'Might enable some equalizer apps to work. Requires restart of Freezer': + "Podría activar dalguna aplicación ecualizadora. Rique'l reaniciu de Freezer", + 'Visualizer': 'Visualizador', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + "Amuesa los visualizadores na páxina de la lletra. ALVERTENCIA: ¡Rique'l permisu del micrófonu!", + 'Tags': 'Etiquetes', + 'Album': 'Álbum', + 'Track number': 'Númberu de la pista', + 'Disc number': 'Númberu del discu', + 'Album artist': 'Artista del álbum', + 'Date/Year': 'Data', + 'Label': 'Empresa discográfica', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Total de pistes', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Lletra ensin sincronizar', + 'Genre': 'Xéneru', + 'Contributors': 'Collaboradores', + 'Album art': 'Portada del álbum', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer nun ta disponible nel to país y ReFreezer pue nun funcionar afayadizamente. Usa una VPN', + 'Deezer is unavailable': 'Deezer nun ta disponible', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Corréu electrónicu', + 'Missing email or password!': + "¡Falta'l corréu electrónicu o la contraseña!", + 'Error logging in using email, please check your credentials.\nError:': + "Hebo un fallu al aniciar sesión col corréu electrónicu, comprueba los datos d'accesu.\nFallu:", + 'Error logging in!': '¡Hebo un fallu al aniciar sesión!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + "Importa llistes de Spotify d'hasta 100 pistes ensin aniciar sesión.", + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Esti importador rique una ID y un secretu de veceru de Spotify. Pa consiguilos:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Vete pa: developer.spotify.com/dashboard y crea una aplicación.', + 'Open in Browser': 'Abrir nel restolador web', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'ID de veceru', + 'Client Secret': 'Secretu de veceru', + 'Authorize': 'Autorizar', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Opciones', + 'Invalid/Unsupported URL': 'La URL nun ye válida o nun se sofita', + 'Please wait...': 'Espera…', + 'Login using email': 'Login using email', + 'Track removed from offline!': '¡La pista quitóse del mou ensin conexón!', + 'Removed album from offline!': "¡L'álbum quitóse del mou ensin conexón!", + 'Playlist removed from offline!': + '¡La llista quitóse del mou ensin conexón!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'bul_bg': { + 'Home': 'Начало', + 'Search': 'Търсене', + 'Library': 'Библиотека', + "Offline mode, can't play flow or smart track lists.": + 'В офлайн режим сте. Нямате достъп до безкраен микс.', + '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!': 'Грешка при зареждане на данните!', + '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': 'Free space', + 'Loved tracks': 'Loved tracks', + 'Favorites': 'Favorites', + 'All offline tracks': 'All offline tracks', + 'Create new playlist': 'Create new playlist', + 'Cannot create playlists in offline mode': + 'Cannot create playlists in offline mode', + 'Error': 'Error', + 'Error logging in! Please check your token and internet connection and try again.': + 'Error logging in! Please check your token and internet connection and try again.', + 'Dismiss': 'Dismiss', + 'Welcome to': 'Welcome to', + 'Please login using your Deezer account.': + 'Please login using your Deezer account.', + 'Login using browser': 'Login using browser', + 'Login using token': 'Login using token', + 'Enter ARL': 'Enter ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Save', + "If you don't have account, you can register on deezer.com for free.": + "If you don't have account, you can register on deezer.com for free.", + 'Open in browser': 'Open in browser', + "By using this app, you don't agree with the Deezer ToS": + "By using this app, you don't agree with the Deezer ToS", + 'Play next': 'Play next', + 'Add to queue': 'Add to queue', + 'Add track to favorites': 'Add track to favorites', + 'Add to playlist': 'Add to playlist', + 'Select playlist': 'Select playlist', + 'Track added to': 'Track added to', + 'Remove from playlist': 'Remove from playlist', + 'Track removed from': 'Track removed from', + 'Remove favorite': 'Remove favorite', + 'Track removed from library': 'Track removed from library', + 'Go to': 'Go to', + 'Make offline': 'Make offline', + 'Add to library': 'Add to library', + 'Remove album': 'Remove album', + 'Album removed': 'Album removed', + 'Remove from favorites': 'Remove from favorites', + 'Artist removed from library': 'Artist removed from library', + 'Add to favorites': 'Add to favorites', + 'Remove from library': 'Remove from library', + 'Add playlist to library': 'Add playlist to library', + 'Added playlist to library': 'Added playlist to library', + 'Make playlist offline': 'Make playlist offline', + 'Download playlist': 'Download playlist', + 'Create playlist': 'Create playlist', + 'Title': 'Title', + 'Description': 'Description', + 'Private': 'Private', + 'Collaborative': 'Collaborative', + 'Create': 'Create', + 'Playlist created!': 'Playlist created!', + 'Playing from:': 'Playing from:', + 'Queue': 'Опашка', + 'Offline search': 'Офлайн търсене', + 'Search Results': 'Резултати от търсене', + 'No results!': 'Няма резултати!', + 'Show all tracks': 'Показване на всички песни', + 'Show all playlists': 'Show all playlists', + 'Settings': 'Settings', + 'General': 'General', + 'Appearance': 'Appearance', + 'Quality': 'Quality', + 'Deezer': 'Deezer', + 'Theme': 'Theme', + 'Currently': 'Currently', + 'Select theme': 'Select theme', + 'Dark': 'Dark', + 'Black (AMOLED)': 'Black (AMOLED)', + 'Deezer (Dark)': 'Deezer (Dark)', + 'Primary color': 'Primary color', + 'Selected color': 'Selected color', + 'Use album art primary color': 'Use album art primary color', + 'Warning: might be buggy': 'Warning: might be buggy', + 'Mobile streaming': 'Mobile streaming', + 'Wifi streaming': 'Wifi streaming', + 'External downloads': 'External downloads', + 'Content language': 'Content language', + 'Not app language, used in headers. Now': + 'Not app language, used in headers. Now', + 'Select language': 'Select language', + 'Content country': 'Content country', + 'Country used in headers. Now': 'Country used in headers. Now', + 'Log tracks': 'Log tracks', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Send track listen logs to Deezer, enable it for features like Flow to work properly', + 'Offline mode': 'Offline mode', + 'Will be overwritten on start.': 'Will be overwritten on start.', + 'Error logging in, check your internet connections.': + 'Error logging in, check your internet connections.', + 'Logging in...': 'Logging in...', + 'Download path': 'Download path', + 'Downloads naming': 'Downloads naming', + 'Downloaded tracks filename': 'Downloaded tracks filename', + 'Valid variables are': 'Valid variables are', + 'Reset': 'Reset', + '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 disks', + 'Overwrite already downloaded files': 'Overwrite already downloaded files', + 'Copy ARL': 'Copy ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copy userToken/ARL Cookie for use in other apps.', + 'Copied': 'Copied', + 'Log out': 'Log out', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Due to plugin incompatibility, login using browser is unavailable without restart.', + '(ARL ONLY) Continue': '(ARL ONLY) Continue', + 'Log out & Exit': 'Log out & Exit', + 'Pick-a-Path': 'Pick-a-Path', + 'Select storage': 'Select storage', + 'Go up': 'Go up', + 'Permission denied': 'Permission denied', + 'Language': 'Language', + 'Language changed, please restart ReFreezer to apply!': + 'Language changed, please restart ReFreezer to apply!', + 'Importing...': 'Importing...', + '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.', + 'Storage permission denied!': 'Storage permission denied!', + 'Failed': 'Failed', + 'Queued': 'Queued', + 'External': 'Storage', + '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': 'Concurrent downloads', + '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!', + 'Save cover file for every track': 'Save cover file for every track', + 'Download Log': 'Download Log', + 'Repository': 'Repository', + 'Source code, report issues there.': 'Source code, report issues there.', + 'Use system theme': 'Use system theme', + 'Light': 'Light', + 'Popularity': 'Popularity', + 'User': 'User', + 'Track count': 'Track count', + "If you want to use custom directory naming - use '/' as directory separator.": + "If you want to use custom directory naming - use '/' as directory separator.", + 'Share': 'Share', + 'Save album cover': 'Save album cover', + 'Warning': 'Warning', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Using too many concurrent downloads on older/weaker devices might cause crashes!', + 'Create .nomedia files': 'Create .nomedia files', + 'To prevent gallery being filled with album art': + 'To prevent gallery being filled with album art', + 'Sleep timer': 'Sleep timer', + 'Minutes:': 'Minutes:', + 'Hours:': 'Hours:', + 'Cancel current timer': 'Cancel current timer', + 'Current timer ends at': 'Current timer ends at', + 'Smart track list': 'Smart track list', + 'Shuffle': 'Shuffle', + 'Library shuffle': 'Library shuffle', + 'Ignore interruptions': 'Ignore interruptions', + 'Requires app restart to apply!': 'Requires app restart to apply!', + 'Ask before downloading': 'Ask before downloading', + 'Search history': 'Search history', + 'Clear search history': 'Clear search history', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Login to enable scrobbling.', + 'Login to LastFM': 'Login to LastFM', + 'Username': 'Username', + 'Password': 'Password', + 'Login': 'Login', + 'Authorization error!': 'Authorization error!', + 'Logged out!': 'Logged out!', + 'Lyrics': 'Lyrics', + 'Player gradient background': 'Player gradient background', + 'Updates': 'Updates', + 'You are running latest version!': 'You are running latest version!', + 'New update available!': 'New update available!', + 'Current version: ': 'Current version: ', + 'Unsupported platform!': 'Unsupported platform!', + 'Freezer Updates': 'Freezer Updates', + 'Update to latest version in the settings.': + 'Update to latest version in the settings.', + 'Release date': 'Release date', + 'Shows': 'Shows', + 'Charts': 'Charts', + 'Browse': 'Browse', + 'Quick access': 'Quick access', + 'Play mix': 'Play mix', + 'Share show': 'Share show', + 'Date added': 'Date added', + 'Discord': 'Discord', + 'Official Discord server': 'Official Discord server', + 'Restart of app is required to properly log out!': + 'Restart of app is required to properly log out!', + 'Artist separator': 'Artist separator', + 'Singleton naming': 'Singleton naming', + 'Keep the screen on': 'Keep the screen on', + 'Wakelock enabled!': 'Wakelock enabled!', + 'Wakelock disabled!': 'Wakelock disabled!', + 'Show all shows': 'Show all shows', + 'Episodes': 'Episodes', + 'Show all episodes': 'Show all episodes', + 'Album cover resolution': 'Album cover resolution', + "WARNING: Resolutions above 1200 aren't officially supported": + "WARNING: Resolutions above 1200 aren't officially supported", + 'Album removed from library!': 'Албумът е премахнат от библиотеката!', + 'Remove offline': 'Премахване от офлайн слушане', + 'Playlist removed from library!': 'Плейлистът е премахнат от библиотеката!', + 'Blur player background': 'Размиване на фона на плейъра', + 'Might have impact on performance': + 'Може да окаже влияние върху производителността', + 'Font': 'Шрифт', + 'Select font': 'Избор на шрифт', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Това приложение не поддържа много шрифтове и в последствие могат да се нарушат оформленията на дадени елементи и да се причини препълване на данните. Внимавайте с употребата на тази настройка!', + 'Enable equalizer': 'Включване на еквалайзер', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Може да включи някои приложения за еквалайзер, за да сработи. Необходимо е да рестартирате Freezer', + 'Visualizer': 'Визуализатор', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Покажи визуализатора на страницата с текстове на песните. ВНИМАНИЕ: Изисква разрешение за микрофон!', + 'Tags': 'Тагове', + 'Album': 'Албум', + 'Track number': 'Песен №', + 'Disc number': 'Диск №', + 'Album artist': 'Изпълнител на албума', + 'Date/Year': 'Дата/Година', + 'Label': 'Звукозаписна компания', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Общ брой песни', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Несинхронизирани текстове на песни', + 'Genre': 'Жанр', + 'Contributors': 'Сътрудници', + 'Album art': 'Обложка на албума', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer не е достъпен във вашата държава и в последствие някои функционалности на ReFreezer може да не са използваеми. Моля, ползвайте VPN', + 'Deezer is unavailable': 'Deezer не е достъпен', + 'Continue': 'Продължаване', + 'Email Login': 'Влизане с е-поща', + 'Email': 'Е-поща', + 'Missing email or password!': 'Грешна/непопълнена е-поща или парола!', + 'Error logging in using email, please check your credentials.\nError:': + 'Възникна проблем при влизането с е-поща. Моля, проверете си въведените данни. Грешка:', + 'Error logging in!': 'Грешка при влизане!', + 'Change display mode': 'Промяна на режима на дисплея', + 'Enable high refresh rates': 'Включване на високи честоти на опресняване', + 'Display mode': 'Режим на дисплей', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Импортиране на Spotify плейлисти с предел до 100 песни без да сте влезли в профила си.', + 'Download imported tracks': 'Изтегляне на импортирани песни', + 'Start import': 'Започване на импортирането', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Импортиране на всякакъв Spotify плейлист. Импортиране от собствената библиотека на Spotify. Изисква безплатен акаунт.', + 'Spotify Importer v2': 'Импортиране на Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Този импортер изисква клиентския идентификационен номер и таен ключ от Spotify. За да се сдобиете с тях, трябва да:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Отидете в: developer.spotify.com/dashboard и да създадете приложение.', + 'Open in Browser': 'Отваряне в браузър', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. В приложението, което току-що създадохте, влезте в Настройки и сложете следния Redirect URL: ', + 'Copy the Redirect URL': 'Копиране на Redirect URL', + 'Client ID': 'Идентификационен номер на клиента', + 'Client Secret': 'Секретен ключ на клиента', + 'Authorize': 'Упълномощение', + 'Logged in as: ': 'Влезли сте като: ', + 'Import playlists by URL': 'Импортиране на плейлисти чрез URL', + 'URL': 'URL', + 'Options': 'Настройки', + 'Invalid/Unsupported URL': 'Невалиден/Неподдържан URL', + 'Please wait...': 'Моля, изчакайте...', + 'Login using email': 'Влез с е-поща', + 'Track removed from offline!': 'Песента е премахната от офлайн слушане!', + 'Removed album from offline!': 'Албумът е премахнат от офлайн слушане!', + 'Playlist removed from offline!': + 'Плейлистът е премахнат от офлайн слушане!', + 'Repeat': 'Повторение', + 'Repeat one': 'Повторение на текущ запис', + 'Repeat off': 'Изключване на повторение', + 'Love': 'Обичам', + 'Unlove': 'Премахни от обичани', + 'Dislike': 'Премахни от харесани', + 'Close': 'Затваряне', + 'Sort playlist': 'Сортиране на плейлист', + 'Sort ascending': 'Сортирай възходящо', + 'Sort descending': 'Сортирай низходящо', + 'Stop': 'Спиране', + 'Start': 'Начало', + 'Clear all': 'Изчистване на всичко', + 'Play previous': 'Пускане на предишното', + 'Play': 'Пускане', + 'Pause': 'Пауза', + 'Remove': 'Премахване', + 'Seekbar': 'Лента за време', + 'Singles': 'Сингли', + 'Featured': 'Избрани', + 'Fans': 'Фенове', + 'Duration': 'Времетраене', + 'Sort': 'Сортиране', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Вашият ARL вероятно е изтекъл. Опитайте се да влезете в профила си отново, използвайки нов ARL или чрез браузър.' + }, + 'zh-cn': { + '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!': '链接加载错误', + '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.': + '登录出错,请检查您的 Token 和网络连接,然后重试', + 'Dismiss': '忽略', + 'Welcome to': '欢迎使用', + 'Please login using your Deezer account.': '请使用您的 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 上免费注册', + 'Open in browser': '使用浏览器打开', + "By using this app, you don't agree with the Deezer ToS": + '使用这个软件,代表您不同意 Deezer 的使用条款', + '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': '选择主题', + 'Dark': '暗色', + 'Black (AMOLED)': '纯黑 (AMOLED)', + 'Deezer (Dark)': 'Deezer (暗色)', + 'Primary color': '主色调', + 'Selected color': '已选颜色', + 'Use album art primary color': '使用专辑封面主色调', + 'Warning: might be buggy': '警告:此功能不稳定', + 'Mobile streaming': '移动数据播放', + 'Wifi streaming': 'WiFi 播放', + '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.': + '复制 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': '选取一个路径', + 'Select storage': '选择存储位置', + 'Go up': '向上一级', + 'Permission denied': '获取权限失败', + 'Language': '语言', + 'Language changed, please restart ReFreezer to apply!': '语言已更改,请重启生效', + 'Importing...': '正在导入中', + 'Radio': '电台', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Deezer 没有这首歌', + 'Failed to download track! Please restart.': '下载歌曲失败,请重启', + 'Storage permission denied!': '无法获取存储权限', + 'Failed': '失败', + 'Queued': '已添加到列队', + 'External': '本地文件', + 'Restart failed downloads': '重试失败的下载任务', + 'Clear failed': '清空失败的下载任务', + 'Download Settings': '下载', + 'Create folder for playlist': '创建播放列表文件夹', + 'Download .LRC lyrics': '下载 .LRC 歌词', + 'Proxy': '代理', + 'Not set': '未设置', + 'Search or paste URL': '搜索或输入网址', + 'History': '播放记录', + 'Download threads': '同时下载任务数', + 'Lyrics unavailable, empty or failed to load!': '未找到歌词,歌词不存在或加载失败', + 'About': '关于', + 'Telegram Channel': 'Telegram 频道', + 'To get latest releases': '获取最新版本', + 'Official chat': '官方群', + 'Telegram Group': 'Telegram 群组', + 'Huge thanks to all the contributors! <3': '非常感谢所有贡献者 <3', + 'Edit playlist': '编辑播放列表', + 'Update': '更新', + 'Playlist updated!': '播放列表更新成功', + 'Downloads added!': '成功添加到下载', + 'Save cover file for every track': '每首歌曲都下载封面', + 'Download Log': '下载日志', + 'Repository': '源码库', + 'Source code, report issues there.': '源代码,在这里反馈问题', + 'Use system theme': '使用系统主题', + 'Light': '浅色', + 'Popularity': '热门程度', + 'User': '用户', + 'Track count': '歌曲数', + "If you want to use custom directory naming - use '/' as directory separator.": + '如果您想使用自定义目录名称 - 使用“/”作为目录分隔符', + 'Share': '分享', + 'Save album cover': '下载专辑封面', + 'Warning': '警告', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + '太多同时下载任务,在低性能设备上可能会崩溃', + 'Create .nomedia files': '创建 .nomedia 文件', + 'To prevent gallery being filled with album art': '为了防止本地图库有太多专辑封面', + 'Sleep timer': '定时关闭', + 'Minutes:': '分:', + 'Hours:': '时:', + 'Cancel current timer': '取消当前定时', + 'Current timer ends at': '当前定时将结束于', + 'Smart track list': '智能歌曲列表', + 'Shuffle': '随机播放', + 'Library shuffle': '曲库随机播放', + 'Ignore interruptions': '忽略中断', + 'Requires app restart to apply!': '需要重启生效', + 'Ask before downloading': '下载前询问', + 'Search history': '搜索记录', + 'Clear search history': '清除搜索记录', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': '登录以上传播放记录到 LastFM', + 'Login to LastFM': '登录 LastFM', + 'Username': '用户名', + 'Password': '密码', + 'Login': '登录', + 'Authorization error!': '授权错误', + 'Logged out!': '已注销', + 'Lyrics': '歌词', + 'Player gradient background': '播放界面背景渐变', + 'Updates': '更新', + 'You are running latest version!': '已是最新版本', + 'New update available!': '检测到新版本', + 'Current version: ': '当前版本:', + 'Unsupported platform!': '不支持的平台', + 'Freezer Updates': 'Freezer 更新', + 'Update to latest version in the settings.': '在设置中更新到最新版本', + 'Release date': '发布日期', + 'Shows': '播客', + 'Charts': '排行榜', + 'Browse': '浏览', + 'Quick access': '快速访问', + 'Play mix': '电台', + 'Share show': '分享播客', + 'Date added': '添加日期', + 'Discord': 'Discord', + 'Official Discord server': '官方 Discord 群', + 'Restart of app is required to properly log out!': '需要重新启动才能注销', + 'Artist separator': '歌手名分隔符', + 'Singleton naming': '单曲专辑命名', + 'Keep the screen on': '保持屏幕常亮', + 'Wakelock enabled!': '唤醒锁定已启用', + 'Wakelock disabled!': '唤醒锁定已停用', + 'Show all shows': '显示所有播客', + 'Episodes': '单集', + 'Show all episodes': '展示所有集数', + 'Album cover resolution': '专辑封面分辨率', + "WARNING: Resolutions above 1200 aren't officially supported": + '警告:官方不支持高于 1200 的分辨率', + 'Album removed from library!': '专辑已从曲库中移除', + 'Remove offline': '删除离线缓存', + 'Playlist removed from library!': '播放列表已从曲库中移除', + 'Blur player background': '播放界面背景模糊', + 'Might have impact on performance': '可能对性能有影响', + 'Font': '字体', + 'Select font': '选择字体', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + '不兼容所有字体,可能会扰乱布局或溢出界面,请谨慎使用', + 'Enable equalizer': '启用均衡器', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + '可以调用某些均衡器软件,需要重启生效', + 'Visualizer': '音乐可视化', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + '在歌词界面显示可视化效果。警告: 需要麦克风权限!', + 'Tags': '标签', + 'Album': '专辑', + 'Track number': '歌曲编号', + 'Disc number': '专辑碟号', + 'Album artist': '专辑歌手', + 'Date/Year': '日期/年份', + 'Label': '音乐厂牌', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': '歌曲总数', + 'BPM': '节拍速度', + 'Unsynchronized lyrics': '未同步歌词', + 'Genre': '乐种', + 'Contributors': '贡献者', + 'Album art': '专辑封面', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'hr_hr': { + 'Home': 'Početna', + 'Search': 'Pretraži', + 'Library': 'Biblioteka', + "Offline mode, can't play flow or smart track lists.": + 'Izvanmrežni način, ne može se reproducirati flow ili pametni popis pjesama.', + 'Added to library': 'Dodano u biblioteku', + 'Download': 'Preuzmi', + 'Disk': 'Disk', + 'Offline': 'Izvranmrežno', + 'Top Tracks': 'Najslušanije pjesme', + 'Show more tracks': 'Prikaži više pjesama', + 'Top': 'Najslušanije', + 'Top Albums': 'Najslušaniji albumi', + 'Show all albums': 'Prikaži sve albume', + 'Discography': 'Diskografija', + 'Default': 'Zadano', + 'Reverse': 'Obrnuto', + 'Alphabetic': 'Abecedno', + 'Artist': 'Izvođač', + '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': 'Preuzimanja', + 'Clear queue': 'Očisti red', + "This won't delete currently downloading item": + 'Ovo neće izbrisati stavku koja se trenutno preuzima', + 'Are you sure you want to delete all queued downloads?': + 'Jeste li sigurni da želite izbrisati sva preuzimanja 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) preuzimanja', + 'Please check your connection and try again later...': + 'Provjerite svoju internet vezu i pokušajte ponovo...', + '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 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': 'Slobodni prostor', + 'Loved tracks': 'Voljene pjesme', + 'Favorites': 'Favoriti', + 'All offline tracks': 'Sve izvanmrež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': 'Odbaci', + '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': 'Unesite 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': 'Reproduciraj sljedeće', + 'Add to queue': 'Dodaj u red čekanja', + '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': 'Izvođač 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': 'Stvori popis za reprodukciju', + 'Title': 'Naslov', + 'Description': 'Opis', + 'Private': 'Privatno', + 'Collaborative': 'Suradnički', + 'Create': 'Stvori', + 'Playlist created!': 'Popis za reprodukciju je napravljen!', + 'Playing from:': 'Svira iz:', + 'Queue': 'Red čekanja', + 'Offline search': 'Izvanmrežno traženje', + 'Search Results': 'Rezultati pretraživanja', + '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': 'Kvaliteta', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Trenutno', + 'Select theme': 'Izaberi temu', + 'Dark': 'Tamno', + 'Black (AMOLED)': 'Crno (AMOLED)', + 'Deezer (Dark)': 'Deezer (tamno)', + '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': 'Streamanje preko mobilnih podataka', + 'Wifi streaming': 'Streamanje preko wifi-a', + 'External downloads': 'Vanjska skidanja', + 'Content language': 'Jezik sadržaja', + 'Not app language, used in headers. Now': + 'Nije jezik aplikacije, korišteno u zaglavljima. Sada', + 'Select language': 'Izaberi jezik', + 'Content country': 'Zemlja sadržaja', + 'Country used in headers. Now': 'Zemlja korištena u zaglavljima. Sada', + '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žni način', + 'Will be overwritten on start.': 'Biti će prepisano 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': 'Putanja preuzimanja', + 'Downloads naming': 'Imenovanja preuzimanja', + 'Downloaded tracks filename': 'Naziv datoteka preuzetih pjesama', + 'Valid variables are': 'Važeće varijable su', + 'Reset': 'Resetiraj', + 'Clear': 'Očisti', + 'Create folders for artist': 'Stvori mape za izvođače', + 'Create folders for albums': 'Stvori mape za albume', + 'Separate albums by discs': 'Odvoji albume po diskovima', + 'Overwrite already downloaded files': 'Prepiši preko već skinutih datoteka', + 'Copy ARL': 'Kopiraj ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Kopiraj userToken/ARL kolačić 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 putanju', + 'Select storage': 'Odaberite prostor za pohranu', + 'Go up': 'Idi gore', + 'Permission denied': 'Dopuštenje odbijeno', + 'Language': 'Jezik', + 'Language changed, please restart ReFreezer to apply!': + 'Jezik je promijenjen, molimo vas da ponovno pokrenete ReFreezer kako bi se promjene primijenile!', + 'Importing...': 'Uvoz...', + '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 pokrenete.', + 'Storage permission denied!': 'Odbijeno dopuštenje za pohranu!', + 'Failed': 'Neuspješno', + 'Queued': 'U redu čekanja', + 'External': 'Pohrana', + 'Restart failed downloads': 'Ponovno preuzmi neuspješna preuzimanja', + 'Clear failed': 'Izbriši neuspješna preuzimanja', + 'Download Settings': 'Postavke preuzimanja', + 'Create folder for playlist': 'Stvori mapu za popis za reprodukciju', + 'Download .LRC lyrics': 'Preuzmi .LRC tekstove', + 'Proxy': 'Proxy', + 'Not set': 'Nije postavljeno', + 'Search or paste URL': 'Pretraži ili zalijepi URL', + 'History': 'Povijest', + 'Download threads': 'Istovremena preuzimanja', + 'Lyrics unavailable, empty or failed to load!': + 'Tekstovi riječi nedostupni, prazni ili se nisu uspješno učitali!', + 'About': 'O aplikaciji', + 'Telegram Channel': 'Telegram kanal', + 'To get latest releases': 'Da biste dobili zadnja izdanja', + 'Official chat': 'Službeni chat', + 'Telegram Group': 'Telegram grupa', + 'Huge thanks to all the contributors! <3': + 'Veliko hvala svim suradnicima! <3', + 'Edit playlist': 'Uredi popis za reprodukciju', + 'Update': 'Ažuriraj', + 'Playlist updated!': 'Popis za reprodukciju je ažuriran!', + 'Downloads added!': 'Preuzimanja dodana!', + 'Save cover file for every track': 'Spremi omot za svaku pjesmu', + 'Download Log': 'Preuzmi zapisnik', + 'Repository': 'Repozitorij', + 'Source code, report issues there.': 'Izvorni kod, prijavi probleme tamo.', + 'Use system theme': 'Koristi temu sustava', + 'Light': 'Svijetla', + 'Popularity': 'Popularnost', + 'User': 'Korisnik', + 'Track count': 'Broj pjesme', + "If you want to use custom directory naming - use '/' as directory separator.": + "Ako želite koristiti prilagođeno imenovanje direktorija - koristite '/' kao razdjelnik direktorija.", + 'Share': 'Podijeli', + 'Save album cover': 'Spremi omot albuma', + 'Warning': 'Upozorenje', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Korištenje previše istovremenih preuzimanja na starijim/slabijim uređajima može prouzrokovati rušenja aplikacije!', + 'Create .nomedia files': 'Kreiraj .nomedia dateoteke', + 'To prevent gallery being filled with album art': + 'Da biste spriječili popunjavanje galerije sa omotima albuma', + 'Sleep timer': 'Tajmer za spavanje', + 'Minutes:': 'Minuta:', + 'Hours:': 'Sati:', + 'Cancel current timer': 'Otkaži trenutni tajmer', + 'Current timer ends at': 'Trenutni tajmer završava na', + 'Smart track list': 'Pametni popis pjesama', + 'Shuffle': 'Nasumično', + 'Library shuffle': 'Izmiješaj biblioteku', + 'Ignore interruptions': 'Ignoriraj prekide', + 'Requires app restart to apply!': + 'Zahtjeva ponovno pokretanje da bi se primijenilo!', + 'Ask before downloading': 'Pitaj prije preuzimanja', + 'Search history': 'Povijest pretraživanja', + 'Clear search history': 'Očisti povijest pretraživanja', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Prijavi se da omogućiš skrobiranje.', + 'Login to LastFM': 'Prijavi se u LastFM', + 'Username': 'Korisničko ime', + 'Password': 'Lozinka', + 'Login': 'Prijava', + 'Authorization error!': 'Pogreška autorizacije!', + 'Logged out!': 'Odjavljeni ste!', + 'Lyrics': 'Tekst pjesme', + 'Player gradient background': 'Gradijent pozadina svirača', + 'Updates': 'Ažuriranja', + 'You are running latest version!': 'Koristite posljednju verziju!', + 'New update available!': 'Dostupno je novo ažuriranje!', + 'Current version: ': 'Trenutna verzija: ', + 'Unsupported platform!': 'Nepodržana platforma!', + 'Freezer Updates': 'Freezer ažuriranja', + 'Update to latest version in the settings.': + 'Ažurirajte na posljednju verziju u postavkama.', + 'Release date': 'Datum izdavanja', + 'Shows': 'Emisije', + 'Charts': 'Ljestvice', + 'Browse': 'Pregledaj', + 'Quick access': 'Brzi pristup', + 'Play mix': 'Sviraj miks', + 'Share show': 'Podijeli emisiju', + 'Date added': 'Datum dodavanja', + 'Discord': 'Discord', + 'Official Discord server': 'Službeni Discord server', + 'Restart of app is required to properly log out!': + 'Ponovno pokretanje aplikacije je potrebno kako biste se ispravno odjavili!', + 'Artist separator': 'Razdvajač izvođača', + 'Singleton naming': 'Imenovanje datoteke za samostalne pjesme', + 'Keep the screen on': 'Zadrži zaslon uključenim', + 'Wakelock enabled!': 'Wakelock omogućen!', + 'Wakelock disabled!': 'Wakelock onemogućen!', + 'Show all shows': 'Prikaži sve emisije', + 'Episodes': 'Epizode', + 'Show all episodes': 'Prikaži sve epizode', + 'Album cover resolution': 'Rezolucija naslovnice albuma', + "WARNING: Resolutions above 1200 aren't officially supported": + 'UPOZORENJE: Rezolucije iznad 1200 nisu službeno podržane', + 'Album removed from library!': 'Album je uklonjen iz biblioteke!', + 'Remove offline': 'Izbriši izvanmrežno', + 'Playlist removed from library!': + 'Popis za reprodukciju je izbrisan iz biblioteke!', + 'Blur player background': 'Zamuti pozadinu svirača', + 'Might have impact on performance': + 'Moglo bi imati utjecaja na performanse', + 'Font': 'Font', + 'Select font': 'Odaberi font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Ova aplikacija nije napravljena da podržava mnogo fontova. Korištenjem različitih fontova može se prelomiti raspored i prijelom teksta. Koristite na vlastiti rizik!', + 'Enable equalizer': 'Omogući ekvilajzer', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Moglo bi omogućiti nekim ekvilajzer aplikacijama da ispravno rade. Zahtijeva restart Freezera', + 'Visualizer': 'Vizualizator', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Pokaži vizualizacije na stranici s tekstovima pjesme. UPOZORENJE: Zahtijeva dopuštenje mikrofona!', + 'Tags': 'Oznake', + 'Album': 'Album', + 'Track number': 'Broj pjesme', + 'Disc number': 'Broj diska', + 'Album artist': 'Izvođač albuma', + 'Date/Year': 'Datum/godina', + 'Label': 'Oznaka', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Ukupno pjesama', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Nesinkronizirani tekstovi pjesama', + 'Genre': 'Žanr', + 'Contributors': 'Suradnici', + 'Album art': 'Omot albuma', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer nije dostupan u vašoj zemlji, ReFreezer možda neće pravilno funkcionirati. Molimo koristite VPN', + 'Deezer is unavailable': 'Deezer je nedostupan', + 'Continue': 'Nastavi', + 'Email Login': 'Email prijava', + 'Email': 'Email', + 'Missing email or password!': 'Nedostaje email ili zaporka!', + 'Error logging in using email, please check your credentials.\nError:': + 'Greška prilikom prijavljivanja, molimo provjerite svoju lozinku.\nGreška:', + 'Error logging in!': 'Pogreška prilikom prijavljivanja!', + 'Change display mode': 'Promijeni način prikaza', + 'Enable high refresh rates': 'Omogući visoke brzine osvježavanja zaslona', + 'Display mode': 'Način prikaza', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Uvezi Spotify playliste sa do 100 pjesama bez prijavljivanja.', + 'Download imported tracks': 'Preuzmi uvezene pjesme', + 'Start import': 'Pokreni uvoz', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Uvezi bilo koju Spotify playlistu, uvezi sa vlastite Spotify biblioteke. Zahtijeva besplatni račun.', + 'Spotify Importer v2': 'Spotify uvoznik v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Ovaj uvoznik zahtijeva Spotify Client ID i Client Secret. Da biste ih dobili:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Idite na developer.spotify.com/dashboard i kreirajte aplikaciju.', + 'Open in Browser': 'Otvori u pregledniku', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. U aplikaciji koju ste upravo kreirali idite u postavke i podesite Redirect URL na: ', + 'Copy the Redirect URL': 'Kopiraj Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Autoriziraj', + 'Logged in as: ': 'Prijavljeni kao: ', + 'Import playlists by URL': 'Uvezi playliste po URL-u', + 'URL': 'URL', + 'Options': 'Postavke', + 'Invalid/Unsupported URL': 'Nevažeći/nepodržani URL', + 'Please wait...': 'Molimo pričekajte...', + 'Login using email': 'Prijava putem email', + 'Track removed from offline!': + 'Pjesma je uklonjena iz izvanmrežnog načina rada!', + 'Removed album from offline!': + 'Album je uklonjen iz izvanmrežnog načina rada!', + 'Playlist removed from offline!': + 'Popis za reprodukciju je uklonjen iz izvanmrežnog načina rada!', + 'Repeat': 'Ponovi', + 'Repeat one': 'Ponovi jednom', + 'Repeat off': 'Ponavljanje isključeno', + 'Love': 'Sviđa mi se', + 'Unlove': 'Ne sviđa mi se', + 'Dislike': 'Ne sviđa mi se', + 'Close': 'Zatvori', + 'Sort playlist': 'Sortiraj popis za reprodukciju', + 'Sort ascending': 'Sortiraj uzlazno', + 'Sort descending': 'Sortiraj silazno', + 'Stop': 'Zaustavi', + 'Start': 'Započni', + 'Clear all': 'Obriši sve', + 'Play previous': 'Reproduciraj prethodno', + 'Play': 'Reproduciraj', + 'Pause': 'Pauziraj', + 'Remove': 'Ukloni', + 'Seekbar': 'Traka', + 'Singles': 'Singlovi', + 'Featured': 'Istaknuto', + 'Fans': 'Obožavatelji', + 'Duration': 'Trajanje', + 'Sort': 'Sortiraj', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Tvoj ARL možda je istekao, pokušaj se odjaviti i ponovno prijaviti koristeći novi ARL ili preko preglednika.' + }, + 'cs_cz': { + 'Home': 'Domů', + 'Search': 'Hledat', + 'Library': 'Knihovna', + "Offline mode, can't play flow or smart track lists.": + 'Offline režim, nelze přehrávat toky nebo chytré seznamy skladeb.', + 'Added to library': 'Přidáno do knihovny', + 'Download': 'Stáhnout', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Nejlepší skladby', + 'Show more tracks': 'Zobrazit více skladeb', + 'Top': 'Nejlepší', + 'Top Albums': 'Nejlepší alba', + 'Show all albums': 'Zobrazit všechna alba', + 'Discography': 'Diskografie', + 'Default': 'Výchozí', + 'Reverse': 'Obráceně', + 'Alphabetic': 'Abecedně', + 'Artist': 'Umělec', + 'Post processing...': 'Finální zpracování...', + 'Done': 'Hotovo', + 'Delete': 'Odstranit', + 'Are you sure you want to delete this download?': + 'Jste si jistý že chcete smazat stažené?', + 'Cancel': 'Zrušit', + 'Downloads': 'Stažené', + 'Clear queue': 'Vymazat frontu', + "This won't delete currently downloading item": + 'Tímto nebude odstraněna aktuálně stahovaná položka', + 'Are you sure you want to delete all queued downloads?': + 'Opravdu chcete smazat frontu stažených?', + 'Clear downloads history': 'Vymazat historii stahování', + 'WARNING: This will only clear non-offline (external downloads)': + 'VAROVÁNÍ: Vyčistí se pouze ne-offline (externě stažené)', + 'Please check your connection and try again later...': + 'Prosím zkontrolujte své připojení a zkuste to znovu', + 'Show more': 'Zobrazit více', + 'Importer': 'Importované', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Zatím podpora pouze Spotify s limitem 100 skladeb', + 'Due to API limitations': 'Kvůli omezením API', + 'Enter your playlist link below': 'Zadejte odkaz na playlist níže', + 'Error loading URL!': 'Chyba při načítání URL!', + 'Convert': 'Konvertovat', + 'Download only': 'Pouze ke stažení', + 'Downloading is currently stopped, click here to resume.': + 'Stahování je momentálně zastaveno, klikněte zde pro obnovení.', + 'Tracks': 'Skladby', + 'Albums': 'Alba', + 'Artists': 'Umělci', + 'Playlists': 'Playlisty', + 'Import': 'Importovat', + 'Import playlists from Spotify': 'Importovat playlisty ze Spotify', + 'Statistics': 'Statistiky', + 'Offline tracks': 'Offline skladby', + 'Offline albums': 'Offline alba', + 'Offline playlists': 'Offline playlist', + 'Offline size': 'Režim off-line', + 'Free space': 'Volné místo', + 'Loved tracks': 'Oblíbené skladby', + 'Favorites': 'Oblíbené', + 'All offline tracks': 'Všechny offline skladby', + 'Create new playlist': 'Vytvořit nový playlist', + 'Cannot create playlists in offline mode': + 'Nelze vytvořit playlisty v offline režimu', + 'Error': 'Chyba', + 'Error logging in! Please check your token and internet connection and try again.': + 'Chyba při přihlášení! Zkontrolujte prosím svůj token a připojení k internetu a zkuste to znovu.', + 'Dismiss': 'Zamítnout', + 'Welcome to': 'Vítejte v', + 'Please login using your Deezer account.': + 'Přihlaste se prosím pomocí vašeho Deezer účtu.', + 'Login using browser': 'Přihlásit se pomocí prohlížeče', + 'Login using token': 'Přihlásit se pomocí tokenu', + 'Enter ARL': 'Zadejte ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Uložit', + "If you don't have account, you can register on deezer.com for free.": + 'Pokud nemáte účet, můžete se zdarma zaregistrovat na deezer.com.', + 'Open in browser': 'Otevřít v prohlížečí', + "By using this app, you don't agree with the Deezer ToS": + 'Používáním této aplikace nesouhlasíte s nástrojem Deezer', + 'Play next': 'Přehrát jako další', + 'Add to queue': 'Přidat do fronty', + 'Add track to favorites': 'Přidat skladbu do oblíbených', + 'Add to playlist': 'Přidat do playlistu', + 'Select playlist': 'Vybrat playlist', + 'Track added to': 'Skladba přidána do', + 'Remove from playlist': 'Smazat z playlistu', + 'Track removed from': 'Trasa odebrána z', + 'Remove favorite': 'Odstranit z oblíbených', + 'Track removed from library': 'Skladba odstraněna z knihovny', + 'Go to': 'Přejít na', + 'Make offline': 'Uložit offline', + 'Add to library': 'Přidat do knihovny', + 'Remove album': 'Odstranit album', + 'Album removed': 'Album odstraněno', + 'Remove from favorites': 'Odstranit z oblíbených', + 'Artist removed from library': 'Umělec odstraněn z knihovny', + 'Add to favorites': 'Přidat k oblíbeným', + 'Remove from library': 'Odstranit z knihovny', + 'Add playlist to library': 'Přidat playlist do knihovny', + 'Added playlist to library': 'Playlist byl přidán do knihovny', + 'Make playlist offline': 'Udělat playlist offline', + 'Download playlist': 'Stáhnout playlist', + 'Create playlist': 'Vytvořit playlist', + 'Title': 'Název', + 'Description': 'Popis', + 'Private': 'Soukromé', + 'Collaborative': 'Collaborative', + 'Create': 'Vytvořit', + 'Playlist created!': 'Playlist vytvořen!', + 'Playing from:': 'Přehrávám z:', + 'Queue': 'Fronta', + 'Offline search': 'Offline vyhledávání', + 'Search Results': 'Výsledky vyhledávání', + 'No results!': 'Žádné výsledky!', + 'Show all tracks': 'Zobrazit všechny skladby', + 'Show all playlists': 'Zobrazit všechny playlisty', + 'Settings': 'Nastavení', + 'General': 'Obecné', + 'Appearance': 'Vzhled', + 'Quality': 'Kvalita', + 'Deezer': 'Deezer', + 'Theme': 'Motiv', + 'Currently': 'Aktuálně', + 'Select theme': 'Vyberte motiv', + 'Dark': 'Tmavé', + 'Black (AMOLED)': 'Černé (AMOLED)', + 'Deezer (Dark)': 'Deezer (Tmavé)', + 'Primary color': 'Hlavní barva', + 'Selected color': 'Vybraná barva', + 'Use album art primary color': 'Použít primární barvu alba', + 'Warning: might be buggy': 'Varování: může být chybný', + 'Mobile streaming': 'Mobilní streamování', + 'Wifi streaming': 'Streamování přes Wi-Fi', + 'External downloads': 'Externí stahování', + 'Content language': 'Jazyk obsahu', + 'Not app language, used in headers. Now': + 'Not app language, used in headers. Now', + 'Select language': 'Vyberte jazyk', + 'Content country': 'Content country', + 'Country used in headers. Now': 'Země použita v záhlaví. Nyní', + 'Log tracks': 'Logovat skladby', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Odesílat záznamy o poslouchání do Deezeru, povolte aby funkce, jako je Flow fungovaly správně', + 'Offline mode': 'Offline režim', + 'Will be overwritten on start.': 'Při spuštění bude přepsáno.', + 'Error logging in, check your internet connections.': + 'Chyba při přihlášení, zkontrolujte připojení k internetu.', + 'Logging in...': 'Přihlašování...', + 'Download path': 'Cesta ke stažení', + 'Downloads naming': 'Downloads naming', + 'Downloaded tracks filename': 'Downloaded tracks filename', + 'Valid variables are': 'Valid variables are', + 'Reset': 'Reset', + '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 disks', + 'Overwrite already downloaded files': 'Overwrite already downloaded files', + 'Copy ARL': 'Copy ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copy userToken/ARL Cookie for use in other apps.', + 'Copied': 'Copied', + 'Log out': 'Log out', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Due to plugin incompatibility, login using browser is unavailable without restart.', + '(ARL ONLY) Continue': '(ARL ONLY) Continue', + 'Log out & Exit': 'Log out & Exit', + 'Pick-a-Path': 'Pick-a-Path', + 'Select storage': 'Select storage', + 'Go up': 'Go up', + 'Permission denied': 'Permission denied', + 'Language': 'Language', + 'Language changed, please restart ReFreezer to apply!': + 'Language changed, please restart ReFreezer to apply!', + 'Importing...': 'Importing...', + '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.', + 'Storage permission denied!': 'Storage permission denied!', + 'Failed': 'Failed', + 'Queued': 'Queued', + 'External': 'Storage', + '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': 'Concurrent downloads', + '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': + 'Velký dík všem přispěvatelům! <3', + 'Edit playlist': 'Upravit playlist', + 'Update': 'Aktualizovat', + 'Playlist updated!': 'Playlist aktualizován!', + 'Downloads added!': 'Downloads added!', + 'Save cover file for every track': 'Save cover file for every track', + 'Download Log': 'Stáhnout log', + 'Repository': 'Repozitář', + 'Source code, report issues there.': + 'Zdrojový kód, tam nahlašujte problémy.', + 'Use system theme': 'Použít motiv systému', + 'Light': 'Světlý', + 'Popularity': 'Popularita', + 'User': 'Uživatel', + 'Track count': 'Počet skladeb', + "If you want to use custom directory naming - use '/' as directory separator.": + "If you want to use custom directory naming - use '/' as directory separator.", + 'Share': 'Share', + 'Save album cover': 'Save album cover', + 'Warning': 'Warning', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Using too many concurrent downloads on older/weaker devices might cause crashes!', + 'Create .nomedia files': 'Create .nomedia files', + 'To prevent gallery being filled with album art': + 'To prevent gallery being filled with album art', + 'Sleep timer': 'Sleep timer', + 'Minutes:': 'Minutes:', + 'Hours:': 'Hours:', + 'Cancel current timer': 'Cancel current timer', + 'Current timer ends at': 'Current timer ends at', + 'Smart track list': 'Smart track list', + 'Shuffle': 'Shuffle', + 'Library shuffle': 'Library shuffle', + 'Ignore interruptions': 'Ignore interruptions', + 'Requires app restart to apply!': 'Requires app restart to apply!', + 'Ask before downloading': 'Ask before downloading', + 'Search history': 'Search history', + 'Clear search history': 'Clear search history', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Login to enable scrobbling.', + 'Login to LastFM': 'Login to LastFM', + 'Username': 'Username', + 'Password': 'Password', + 'Login': 'Login', + 'Authorization error!': 'Authorization error!', + 'Logged out!': 'Logged out!', + 'Lyrics': 'Lyrics', + 'Player gradient background': 'Player gradient background', + 'Updates': 'Updates', + 'You are running latest version!': 'You are running latest version!', + 'New update available!': 'New update available!', + 'Current version: ': 'Current version: ', + 'Unsupported platform!': 'Unsupported platform!', + 'Freezer Updates': 'Freezer Updates', + 'Update to latest version in the settings.': + 'Update to latest version in the settings.', + 'Release date': 'Release date', + 'Shows': 'Shows', + 'Charts': 'Charts', + 'Browse': 'Browse', + 'Quick access': 'Quick access', + 'Play mix': 'Play mix', + 'Share show': 'Share show', + 'Date added': 'Date added', + 'Discord': 'Discord', + 'Official Discord server': 'Official Discord server', + 'Restart of app is required to properly log out!': + 'Restart of app is required to properly log out!', + 'Artist separator': 'Artist separator', + 'Singleton naming': 'Singleton naming', + 'Keep the screen on': 'Keep the screen on', + 'Wakelock enabled!': 'Wakelock enabled!', + 'Wakelock disabled!': 'Wakelock disabled!', + 'Show all shows': 'Show all shows', + 'Episodes': 'Episodes', + 'Show all episodes': 'Show all episodes', + 'Album cover resolution': 'Album cover resolution', + "WARNING: Resolutions above 1200 aren't officially supported": + "WARNING: Resolutions above 1200 aren't officially supported", + 'Album removed from library!': 'Album removed from library!', + 'Remove offline': 'Remove offline', + 'Playlist removed from library!': 'Playlist removed from library!', + 'Blur player background': 'Blur player background', + 'Might have impact on performance': 'Might have impact on performance', + 'Font': 'Font', + 'Select font': 'Select font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!", + 'Enable equalizer': 'Enable equalizer', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Might enable some equalizer apps to work. Requires restart of Freezer', + 'Visualizer': 'Visualizer', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Track number', + 'Disc number': 'Disc number', + 'Album artist': 'Album artist', + 'Date/Year': 'Date/Year', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Track total', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Unsynchronized lyrics', + 'Genre': 'Genre', + 'Contributors': 'Contributors', + 'Album art': 'Album art', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'nl_nl': { + 'Home': 'Startpagina', + 'Search': 'Zoek', + 'Library': 'Bibliotheek', + "Offline mode, can't play flow or smart track lists.": + 'Offline modus, kan geen flow of slimme afspeellijst afspelen.', + 'Added to library': 'Toegevoegd aan bibliotheek', + 'Download': 'Download', + 'Disk': 'Schijf', + 'Offline': 'Offline', + 'Top Tracks': 'Top Nummers', + 'Show more tracks': 'Toon meer nummers', + 'Top': 'Top', + 'Top Albums': 'Top Albums', + 'Show all albums': 'Toon alle albums', + 'Discography': 'Discografie', + 'Default': 'Standaard', + 'Reverse': 'Achterwaarts', + 'Alphabetic': 'Alfabetisch', + 'Artist': 'Artiest', + 'Post processing...': 'Bezig met verwerken...', + 'Done': 'Gereed', + 'Delete': 'Verwijder', + 'Are you sure you want to delete this download?': + 'Weet je zeker dat je deze download wilt verwijderen?', + 'Cancel': 'Annuleer', + 'Downloads': 'Downloads', + 'Clear queue': 'Wachtrij wissen', + "This won't delete currently downloading item": + 'Dit zal het momenteel aan het downloaden item niet verwijderen', + 'Are you sure you want to delete all queued downloads?': + 'Weet je zeker dat je alle geplande downloads wilt verwijderen?', + 'Clear downloads history': 'Downloadgeschiedenis wissen', + 'WARNING: This will only clear non-offline (external downloads)': + 'WAARSCHUWING: Dit zal alleen niet-offline (externe downloads) wissen', + 'Please check your connection and try again later...': + 'Controleer uw internetverbinding en probeer het later opnieuw...', + 'Show more': 'Meer tonen', + 'Importer': 'Importeerder', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Momenteel wordt alleen Spotify ondersteund; deze is beperkt tot 100 nummers', + 'Due to API limitations': 'Vanwege API beperkingen', + 'Enter your playlist link below': 'Voer hieronder uw afspeellijst link in', + 'Error loading URL!': 'Fout bij laden URL!', + 'Convert': 'Converteer', + 'Download only': 'Alleen downloaden', + 'Downloading is currently stopped, click here to resume.': + 'Downloaden is momenteel gestopt, klik hier om te hervatten.', + 'Tracks': 'Nummers', + 'Albums': 'Albums', + 'Artists': 'Artiesten', + 'Playlists': 'Afspeellijsten', + 'Import': 'Importeer', + 'Import playlists from Spotify': 'Importeer afspeellijsten van Spotify', + 'Statistics': 'Statistieken', + 'Offline tracks': 'Offline nummers', + 'Offline albums': 'Offline albums', + 'Offline playlists': 'Offline afspeellijsten', + 'Offline size': 'Offline grootte', + 'Free space': 'Beschikbare ruimte', + 'Loved tracks': 'Favoriete nummers', + 'Favorites': 'Favorieten', + 'All offline tracks': 'Alle offline nummers', + 'Create new playlist': 'Nieuwe afspeellijst aanmaken', + 'Cannot create playlists in offline mode': + 'Het maken van afspeellijsten in offlinemodus is niet mogelijk', + 'Error': 'Fout', + 'Error logging in! Please check your token and internet connection and try again.': + 'Fout bij het aanmelden! Controleer uw token en internetverbinding en probeer het opnieuw.', + 'Dismiss': 'Negeer', + 'Welcome to': 'Welkom bij', + 'Please login using your Deezer account.': 'Log in met uw Deezer-account.', + 'Login using browser': 'Aanmelden met browser', + 'Login using token': 'Aanmelden met token', + 'Enter ARL': 'Voer ARL in', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Opslaan', + "If you don't have account, you can register on deezer.com for free.": + 'Als je nog geen account hebt, kun je gratis op deezer.com registreren.', + 'Open in browser': 'In browser openen', + "By using this app, you don't agree with the Deezer ToS": + 'Door gebruik te maken van deze app, ga je niet akkoord met de Deezer ToS', + 'Play next': 'Volgende afspelen', + 'Add to queue': 'Toevoegen aan wachtrij', + 'Add track to favorites': 'Nummer toevoegen aan favorieten', + 'Add to playlist': 'Toevoegen aan afspeellijst', + 'Select playlist': 'Selecteer afspeellijst', + 'Track added to': 'Nummer toegevoegd aan', + 'Remove from playlist': 'Verwijderen uit afspeellijst', + 'Track removed from': 'Nummer verwijderd van', + 'Remove favorite': 'Favoriet verwijderen', + 'Track removed from library': 'Nummer verwijderd uit bibliotheek', + 'Go to': 'Ga naar', + 'Make offline': 'Offline maken', + 'Add to library': 'Toevoegen aan bibliotheek', + 'Remove album': 'Verwijder album', + 'Album removed': 'Album verwijderd', + 'Remove from favorites': 'Verwijder uit favorieten', + 'Artist removed from library': 'Artiest verwijderd uit bibliotheek', + 'Add to favorites': 'Toevoegen aan favorieten', + 'Remove from library': 'Verwijder uit bibliotheek', + 'Add playlist to library': 'Afspeellijst toevoegen aan bibliotheek', + 'Added playlist to library': 'Afspeellijst toegevoegd aan bibliotheek', + 'Make playlist offline': 'Afspeellijst offline maken', + 'Download playlist': 'Afspeellijst downloaden', + 'Create playlist': 'Afspeellijst aanmaken', + 'Title': 'Titel', + 'Description': 'Omschrijving', + 'Private': 'Privé', + 'Collaborative': 'Samenwerkend', + 'Create': 'Aanmaken', + 'Playlist created!': 'Afspeellijst aangemaakt!', + 'Playing from:': 'Afspelen van:', + 'Queue': 'Wachtrij', + 'Offline search': 'Offline zoeken', + 'Search Results': 'Zoekresultaten', + 'No results!': 'Geen resultaten!', + 'Show all tracks': 'Alle nummers weergeven', + 'Show all playlists': 'Alle afspeellijsten weergeven', + 'Settings': 'Instellingen', + 'General': 'Algemeen', + 'Appearance': 'Uiterlijk', + 'Quality': 'Kwaliteit', + 'Deezer': 'Deezer', + 'Theme': 'Thema', + 'Currently': 'Huidig', + 'Select theme': 'Thema selecteren', + 'Dark': 'Donker', + 'Black (AMOLED)': 'Zwart (AMOLED)', + 'Deezer (Dark)': 'Deezer (Donker)', + 'Primary color': 'Primaire kleur', + 'Selected color': 'Geselecteerde kleur', + 'Use album art primary color': + 'Primaire kleur van albumillustratie gebruiken', + 'Warning: might be buggy': 'Waarschuwing: kan bugs tegenkomen', + 'Mobile streaming': 'Mobiel streamen', + 'Wifi streaming': 'WiFi streamen', + 'External downloads': 'Externe downloads', + 'Content language': 'Taal van inhoud', + 'Not app language, used in headers. Now': + 'Niet de app taal, gebruikt in de headers. Nu', + 'Select language': 'Taal selecteren', + 'Content country': 'Land van inhoud', + 'Country used in headers. Now': 'Land gebruikt in headers. Nu', + 'Log tracks': 'Log nummers', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Stuur logs van geluisterde nummers naar Deezer, schakel het in voor functies zoals Flow om het correct te laten werken', + 'Offline mode': 'Offlinemodus', + 'Will be overwritten on start.': 'Wordt bij het opstarten overschreven.', + 'Error logging in, check your internet connections.': + 'Fout bij het aanmelden, controlleer je internetverbinding.', + 'Logging in...': 'Bezig met aanmelden...', + 'Download path': 'Downloadmap', + 'Downloads naming': 'Downloads naamgeving', + 'Downloaded tracks filename': 'Gedownloade nummers bestandsnaam', + 'Valid variables are': 'Geldige variabelen zijn', + 'Reset': 'Herstellen', + 'Clear': 'Wissen', + 'Create folders for artist': 'Mappen voor artiest aanmaken', + 'Create folders for albums': 'Mappen voor artiest aanmaken', + 'Separate albums by discs': 'Albums per disk scheiden', + 'Overwrite already downloaded files': + 'Overschrijf reeds gedownloade bestanden', + 'Copy ARL': 'Kopieer ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Kopieer userToken/ARL Cookie voor gebruik in andere apps.', + 'Copied': 'Gekopieerd', + 'Log out': 'Afmelden', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Door de incompatibiliteit van de plugin is het aanmelden via browser niet mogelijk zonder herstart.', + '(ARL ONLY) Continue': '(ALLEEN VOOR ARL) Doorgaan', + 'Log out & Exit': 'Afmelden & afsluiten', + 'Pick-a-Path': 'Kies-je-Weg', + 'Select storage': 'Opslag kiezen', + 'Go up': 'Omhoog gaan', + 'Permission denied': 'Toegang geweigerd', + 'Language': 'Taal', + 'Language changed, please restart ReFreezer to apply!': + 'Taal veranderd, herstart ReFreezer om het toe te passen!', + 'Importing...': 'Bezig met importeren...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + 'Nummer is niet beschikbaar op Deezer!', + 'Failed to download track! Please restart.': + 'Nummer downloaden mislukt! Herstart opnieuw.', + 'Storage permission denied!': 'Opslag toestemming geweigerd!', + 'Failed': 'Mislukt', + 'Queued': 'In wachtrij', + 'External': 'Opslag', + 'Restart failed downloads': 'Herstart mislukte downloads', + 'Clear failed': 'Wissen mislukt', + 'Download Settings': 'Download instellingen', + 'Create folder for playlist': 'Map voor afspeellijst aanmaken', + 'Download .LRC lyrics': 'Download .LRC songteksten', + 'Proxy': 'Proxy', + 'Not set': 'Niet ingesteld', + 'Search or paste URL': 'Zoek of voer een URL in', + 'History': 'Geschiedenis', + 'Download threads': 'Gelijktijdige downloads', + 'Lyrics unavailable, empty or failed to load!': + 'Songteksten niet beschikbaar, leeg of mislukt om te laden!', + 'About': 'Over', + 'Telegram Channel': 'Telegram kanaal', + 'To get latest releases': 'Voor de nieuwste versies', + 'Official chat': 'Officiële chat', + 'Telegram Group': 'Telegram groep', + 'Huge thanks to all the contributors! <3': + 'Hartelijk dank aan alle bijdragers! <3', + 'Edit playlist': 'Afspeellijst bewerken', + 'Update': 'Bijwerken', + 'Playlist updated!': 'Afspeellijst bijgewerkt!', + 'Downloads added!': 'Downloads toegevoegd!', + 'Save cover file for every track': 'Sla omslagbestand op voor elk nummer', + 'Download Log': 'Logbestand downloaden', + 'Repository': 'Broncode', + 'Source code, report issues there.': 'Bron code, meld problemen daar.', + 'Use system theme': 'Systeemthema gebruiken', + 'Light': 'Licht', + 'Popularity': 'Populariteit', + 'User': 'Gebruiker', + 'Track count': 'Aantal nummers', + "If you want to use custom directory naming - use '/' as directory separator.": + "Als je een map wil gebruiken met een aangepaste naam - gebruik '/' als map scheidingsteken.", + 'Share': 'Delen', + 'Save album cover': 'Albumhoes opslaan', + 'Warning': 'Waarschuwing', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Te veel gelijktijdige downloads op oudere apparaten kunnen crashes veroorzaken!', + 'Create .nomedia files': '.nomedia-bestanden aanmaken', + 'To prevent gallery being filled with album art': + 'Om te voorkomen dat de galerij gevuld wordt met albumillustraties', + 'Sleep timer': 'Slaaptimer', + 'Minutes:': 'Minuten:', + 'Hours:': 'Uren:', + 'Cancel current timer': 'Huidige timer annuleren', + 'Current timer ends at': 'Huidige timer eindigt om', + 'Smart track list': 'Slimme nummerlijst', + 'Shuffle': 'Shuffle', + 'Library shuffle': 'Library shufflen', + 'Ignore interruptions': 'Onderbrekingen negeren', + 'Requires app restart to apply!': + 'Vereist herstart van de app om toe te passen!', + 'Ask before downloading': 'Vragen voordat downloaden wordt gestart', + 'Search history': 'Zoekgeschiedenis', + 'Clear search history': 'Zoekgeschiedenis wissen', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Meld je aan om scrobbling in te schakelen.', + 'Login to LastFM': 'Aanmelden bij LastFM', + 'Username': 'Gebruikersnaam', + 'Password': 'Wachtwoord', + 'Login': 'Aanmelden', + 'Authorization error!': 'Autorisatiefout!', + 'Logged out!': 'Afgemeld!', + 'Lyrics': 'Songteksten', + 'Player gradient background': 'Gradiënt van achtergrond speler', + 'Updates': 'Updates', + 'You are running latest version!': 'Je gebruikt de laatste versie!', + 'New update available!': 'Nieuwe update beschikbaar!', + 'Current version: ': 'Huidige versie: ', + 'Unsupported platform!': 'Niet-ondersteunde platform!', + 'Freezer Updates': 'Freezer Updates', + 'Update to latest version in the settings.': + 'Werk het programma bij in de instellingen.', + 'Release date': 'Publicatiedatum', + 'Shows': 'Shows', + 'Charts': 'Hitlijsten', + 'Browse': 'Bladeren', + 'Quick access': 'Snelle toegang', + 'Play mix': 'Mix afspelen', + 'Share show': 'Show delen', + 'Date added': 'Datum toegevoegd', + 'Discord': 'Discord', + 'Official Discord server': 'Officiële Discord server', + 'Restart of app is required to properly log out!': + 'Herstart app is vereist om correct uit te loggen!', + 'Artist separator': 'Artiest scheidingsteken', + 'Singleton naming': 'Alleenstaande nummers naamgeving', + 'Keep the screen on': 'Scherm aanhouden', + 'Wakelock enabled!': 'Wakelock ingeschakeld!', + 'Wakelock disabled!': 'Wakelock uitgeschakeld!', + 'Show all shows': 'Alle shows weergeven', + 'Episodes': 'Afleveringen', + 'Show all episodes': 'Alle afleveringen weergeven', + 'Album cover resolution': 'Albumhoes resolutie', + "WARNING: Resolutions above 1200 aren't officially supported": + 'WAARSCHUWING: Resoluties boven 1200 worden niet officieel ondersteund', + 'Album removed from library!': 'Album verwijderd uit de bibliotheek!', + 'Remove offline': 'Offline verwijderen', + 'Playlist removed from library!': + 'Afspeellijst uit de bibliotheek verwijderd!', + 'Blur player background': 'Vervaag spelerachtergrond', + 'Might have impact on performance': 'Kan invloed hebben op de prestaties', + 'Font': 'Lettertype', + 'Select font': 'Selecteer lettertype', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Deze app is niet gemaakt voor het ondersteunen van veel lettertypes, het kan de lay-outs en overflow beïnvloeden. Gebruik op eigen risico!', + 'Enable equalizer': 'Equalizer inschakelen', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Kan wellicht sommige equalizer apps laten werken. Vereist het herstarten van Freezer', + 'Visualizer': 'Visualizer', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Toon visualizers op de songtekstpagina. WAARSCHUWING: Vereist toegang tot microfoon!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Liednummer', + 'Disc number': 'Schijfnummer', + 'Album artist': 'Albumartiest', + 'Date/Year': 'Datum/Jaar', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Track totaal', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Niet-gesynchroniseerde songteksten', + 'Genre': 'Genre', + 'Contributors': 'Bijdragers', + 'Album art': 'Album afbeelding', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is niet beschikbaar in jouw land, ReFreezer werkt mogelijk niet correct. Gebruik een VPN', + 'Deezer is unavailable': 'Deezer is niet beschikbaar', + 'Continue': 'Ga door', + 'Email Login': 'E-mail login', + 'Email': 'E-mail', + 'Missing email or password!': 'E-mailadres of wachtwoord ontbreekt!', + 'Error logging in using email, please check your credentials.\nError:': + 'Fout bij inloggen met behulp van e-mail, controleer uw inloggegevens.\nFout:', + 'Error logging in!': 'Fout bij aanmelden!', + 'Change display mode': 'Verander weergavemodus', + 'Enable high refresh rates': 'Hoge verversingssnelheid inschakelen', + 'Display mode': 'Weergavemodus', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importeer Spotify-afspeellijsten tot 100 nummers zonder inloggen.', + 'Download imported tracks': 'Geïmporteerde bestanden downloaden', + 'Start import': 'Begin met importeren', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Importeer een willekeurige Spotify afspeellijst, importeer vanuit een eigen Spotify afspeellijst. Vereist een gratis account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Deze importeur vereist Spotify Client ID en Client Secret. Om deze te verkrijgen:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'In browser openen', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'uwu_uwu': { + 'Home': 'Home', + 'Search': 'Seawch', + 'Library': 'Wibwawy', + "Offline mode, can't play flow or smart track lists.": + "Offwine mode, can't pway fwow ow smawt twack wists.", + 'Added to library': 'Added to wibwawy', + 'Download': 'Downwoad', + 'Disk': 'Disk', + 'Offline': 'Offwine', + 'Top Tracks': 'Top twacks', + 'Show more tracks': 'Show mowe twacks', + 'Top': 'Top', + 'Top Albums': 'Top Awbums', + 'Show all albums': 'Show aww awbums', + 'Discography': 'Discogwaphy', + 'Default': 'Defauwt', + 'Reverse': 'Revewse', + 'Alphabetic': 'Awphabetic', + 'Artist': 'Awtist', + 'Post processing...': 'Pwost pwocessing...', + 'Done': 'Done', + 'Delete': 'Dewete', + 'Are you sure you want to delete this download?': + 'Awe u suwe u wanna dewete dis downwoad?', + 'Cancel': 'Cancew', + 'Downloads': 'Downwoads', + 'Clear queue': 'Cweaw queue', + "This won't delete currently downloading item": + "Dis won't dewete cuwwentwy downwoading item", + 'Are you sure you want to delete all queued downloads?': + 'Awe you suwe you want t-to dewete aww q-queued downwoads!?', + 'Clear downloads history': 'Cweaw downwoads histowy', + 'WARNING: This will only clear non-offline (external downloads)': + 'Warning: dis wiww onwy cweaw non-offwine (extewnaw downwoads)', + 'Please check your connection and try again later...': + 'Pwease check youw connection and twy again watew...', + 'Show more': 'Show mowe', + 'Importer': 'Impowtew', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Cuwwentwy suppowting onwy spotify, with 100 twacks wimit', + 'Due to API limitations': 'Due to api wimitations', + 'Enter your playlist link below': 'Entew youw pwaywist wink bewow', + 'Error loading URL!': 'Ewwow woading url!', + 'Convert': 'Convewt', + 'Download only': 'Downwoad onwy', + 'Downloading is currently stopped, click here to resume.': + 'Downwoading ish cuwwentwy stopped, cwick hewe to wesume.', + 'Tracks': 'Twacks', + 'Albums': 'Awbums', + 'Artists': 'Awtists', + 'Playlists': 'Pwaywists', + 'Import': 'Impowt', + 'Import playlists from Spotify': 'Impowt pwaywists fwom spotify', + 'Statistics': 'Statistics', + 'Offline tracks': 'Offwine twacks', + 'Offline albums': 'Offline albums', + 'Offline playlists': 'Offwine pwaywists', + 'Offline size': 'Offwine size', + 'Free space': 'Fwee space', + 'Loved tracks': 'Loved twacks', + 'Favorites': 'Favowites', + 'All offline tracks': 'Aww offwine twacks', + 'Create new playlist': 'Cweate nyew pwaywist', + 'Cannot create playlists in offline mode': + 'Cannot cweate pwaywists in offwine mode', + 'Error': 'Ewwow', + 'Error logging in! Please check your token and internet connection and try again.': + 'Ewwow wogging in! Pwease check youw token and intewnet connection and twy again.', + 'Dismiss': 'Dismiss', + 'Welcome to': 'Wewcome to', + 'Please login using your Deezer account.': + 'Pwease wogin using youw Deezew account.', + 'Login using browser': 'Login using bwowsew', + 'Login using token': 'Login using token', + 'Enter ARL': 'Entew ARL (retard)', + 'Token (ARL)': 'Token (ARL), use if retarded', + 'Save': 'Savve', + "If you don't have account, you can register on deezer.com for free.": + 'If u dun have account, u can wegistew on deezer.com fow fwee.', + 'Open in browser': 'Open in bwowsew', + "By using this app, you don't agree with the Deezer ToS": + 'By using dis app, u dun agwee with da Deezew ToS', + 'Play next': 'Pway nyext', + 'Add to queue': 'Add t-to queue', + 'Add track to favorites': 'Add twack to favowites', + 'Add to playlist': 'Add t-to pwaywist', + 'Select playlist': 'Sewect pwaywist', + 'Track added to': 'Twack added to', + 'Remove from playlist': 'Wemuv fwom pwaywist', + 'Track removed from': 'Twack wemuvd fwom', + 'Remove favorite': 'Wemuv favowite', + 'Track removed from library': 'Twack wemuvd fwom wibwawy', + 'Go to': 'Go t-to', + 'Make offline': 'Make offwinye', + 'Add to library': 'Add t-to wibwawy', + 'Remove album': 'Wemuv awbum', + 'Album removed': 'Awbum wemuvd', + 'Remove from favorites': 'Wemuv fwom f-favowites', + 'Artist removed from library': 'Awtist wemuvd fwom wibwawy', + 'Add to favorites': 'Add t-to f-favowites', + 'Remove from library': 'Wemuv fwom wibwawy', + 'Add playlist to library': 'Add pwaywist t-to wibwawy', + 'Added playlist to library': 'Added pwaywist t-to wibwawy', + 'Make playlist offline': 'Make pwaywist offwinye', + 'Download playlist': 'Downwoad pwaywist', + 'Create playlist': 'Cweate pwaywist', + 'Title': 'Titwe', + 'Description': 'Descwiption', + 'Private': 'Pwivate', + 'Collaborative': 'Cowwabowative', + 'Create': 'Cweate', + 'Playlist created!': 'Pwaywist cweated?!?! owo', + 'Playing from:': 'Pwaying from:', + 'Queue': 'Quewe', + 'Offline search': 'Offwinye seawch', + 'Search Results': 'Seawch Wesuwts', + 'No results!': 'Nyo resuwts?!?', + 'Show all tracks': 'Show awl twacks', + 'Show all playlists': 'Show awl pwaywists', + 'Settings': 'Settings *^*', + 'General': 'Genyewaw', + 'Appearance': 'Appeawance', + 'Quality': 'Quawity >w<', + 'Deezer': 'Deezew', + 'Theme': 'Theme uwu', + 'Currently': 'C-Cuwwentwy', + 'Select theme': 'Sewect theme', + 'Dark': 'Dawk', + 'Black (AMOLED)': 'B-Bwack (AMOWED)', + 'Deezer (Dark)': 'Deezew (Dawk)', + 'Primary color': 'Pwimawy c-colow', + 'Selected color': 'Sewected c-colow', + 'Use album art primary color': 'Use awbum awt pwimawy c-cowow', + 'Warning: might be buggy': 'Warning: m-might be buggy', + 'Mobile streaming': 'Mobiwe stweaming', + 'Wifi streaming': 'Wifi stweaming', + 'External downloads': 'Extewnyaw downwoads', + 'Content language': 'Content wanguage', + 'Not app language, used in headers. Now': + 'Nyot app wanguage, used in headews. Nyow', + 'Select language': 'Sewect wanguage', + 'Content country': 'Content countwy', + 'Country used in headers. Now': 'Countwy used in headews. Nyow', + 'Log tracks': 'Wog twacks', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Send twack wisten wogs t-to Deezew, enyabwe i-it fow featuwes wike Fwow t-to wowk (・`ω´・) pwopewwy', + 'Offline mode': 'Offwinye mode', + 'Will be overwritten on start.': 'Wiww be uvwwwitten on stawt.', + 'Error logging in, check your internet connections.': + 'Ewwow wogging in, check youw intewnyet connyections.', + 'Logging in...': 'Wogging in... uwu', + 'Download path': 'Downwoad path', + 'Downloads naming': 'Downwoads nyaming', + 'Downloaded tracks filename': 'Downwoaded twacks fiwenyame', + 'Valid variables are': 'Vawid vawiabwes awe', + 'Reset': 'Weset', + 'Clear': 'Cweaw', + 'Create folders for artist': 'Cweate fowdews fow awtist ;;w;;', + 'Create folders for albums': 'Cweate fowdews fow awbums', + 'Separate albums by discs': 'sepawate awbums by disks', + 'Overwrite already downloaded files': 'Ovewwwite awweady downwoaded fiwes', + 'Copy ARL': 'Copy ARL (retard)', + 'Copy userToken/ARL Cookie for use in other apps.': + "Copy usewToken/AWW Cookie fow use in othew apps. (because you're retarded)", + 'Copied': 'Copied', + 'Log out': 'Wog out', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Due t-to pwugin incompatibiwity, wogin u-using bwowsew is unyavaiwabwe without westawt.', + '(ARL ONLY) Continue': '(ARL ONWY) owo Continyue, retard', + 'Log out & Exit': 'Wog out & E-Exit (byeeee uwu)', + 'Pick-a-Path': 'Pick-a-Path', + 'Select storage': 'Sewect stowage ;;w;;', + 'Go up': 'Go up', + 'Permission denied': 'Pewmission denyied', + 'Language': "Wanguage (Don't change me >w<)", + 'Language changed, please restart ReFreezer to apply!': + "Language changed, please restart ReFreezer to apply! (you changed me, nyow I'm gonnya k-kill youw famiwy uwu)", + 'Importing...': 'I-I-Impowting...', + 'Radio': 'Wadio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + 'twack is nyot avaiwabwe on D-Deezew!!11', + 'Failed to download track! Please restart.': + 'Faiwed t-to downwoad twack?!?1 Pwease westawt.', + 'Storage permission denied!': 'Stowage pewmission d-d-denyied!!11', + 'Failed': 'Faiwed', + 'Queued': 'Quewed', + 'External': 'Stowage', + 'Restart failed downloads': 'Westawt faiwed downwoads', + 'Clear failed': 'Cweaw faiwed', + 'Download Settings': 'Downwoad Settings', + 'Create folder for playlist': 'Cweate fowdew fow pwaywist', + 'Download .LRC lyrics': 'Downwoad .LRC lywics', + 'Proxy': 'Pwoxy', + 'Not set': 'Nyot set', + 'Search or paste URL': 'Seawch ow (・`ω´・) paste URL', + 'History': 'Histowy', + 'Download threads': 'Concuwwent downwoads', + 'Lyrics unavailable, empty or failed to load!': + 'Wywics unyavaiwabwe, empty ow (・`ω´・) faiwed t-to woad!!', + 'About': 'About owo', + 'Telegram Channel': 'Tewegwam ÚwÚ Channyew', + 'To get latest releases': 'To get latest reweases', + 'Official chat': 'Officiaw chat', + 'Telegram Group': 'Tewegwam ÚwÚ Gwoup', + 'Huge thanks to all the contributors! <3': + 'Huge t-t-thanks t-to aww the contwibutows!!11 <3', + 'Edit playlist': 'Edit pwaywist', + 'Update': 'Update', + 'Playlist updated!': 'pwaywist updated!!11', + 'Downloads added!': 'Downwoads added!', + 'Save cover file for every track': 'Save cuvw fiwe fow evewy twack', + 'Download Log': 'Downwoad Wog', + 'Repository': 'Wepositowy', + 'Source code, report issues there.': 'Souwce code, wepowt issues thewe.', + 'Use system theme': 'Use system theme', + 'Light': 'Shit theme', + 'Popularity': 'Popuwawity', + 'User': 'Usew', + 'Track count': 'Twack count', + "If you want to use custom directory naming - use '/' as directory separator.": + "If you w-want t-to use custom diwectowy nyaming - use '/'' as diwectowy sepawatow. >w<", + 'Share': 'Shawe', + 'Save album cover': 'Save awbum cuvwr', + 'Warning': 'Wawnying >w<', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Using too many concuwwent downwoads on oldew/weakew devices m-might cause cwashes?!?1 :3', + 'Create .nomedia files': 'Cweate .nyomedia fiwes', + 'To prevent gallery being filled with album art': + 'To pwevent gawwewy b-being fiwwed with awbum awt', + 'Sleep timer': 'Sweep timew', + 'Minutes:': 'Minutes:', + 'Hours:': 'Hours:', + 'Cancel current timer': 'Cancew cuwwent timew', + 'Current timer ends at': 'Cuwwent timew ends at', + 'Smart track list': 'Smawt twack wist', + 'Shuffle': 'Shuffwe >w<', + 'Library shuffle': 'Wibwawy shuffwe', + 'Ignore interruptions': 'Ignyowe intewwuptions', + 'Requires app restart to apply!': + 'Wequiwes app westawt t-to appwy?!?! ;;w;;', + 'Ask before downloading': 'Ask befowe downwoading', + 'Search history': 'Seawch histowy', + 'Clear search history': 'Cleaw seawch histowy', + 'LastFM': 'LastFM uwu', + 'Login to enable scrobbling.': 'wogin ;;w;; t-to enyabwe scwobbwing.', + 'Login to LastFM': 'Login to LastFM', + 'Username': 'Usewnyame', + 'Password': 'Passwowd (is for me?)', + 'Login': 'Login', + 'Authorization error!': 'Authowization erwow?!?! :<', + 'Logged out!': 'W-Wogged out?!?1', + 'Lyrics': 'Wywics', + 'Player gradient background': 'Pwayew gwadient backgwound ', + 'Updates': 'Updates', + 'You are running latest version!': 'You awe wunnying watest vewsion?!?', + 'New update available!': 'Nyew update avaiwabwe?!?! (Yayyyy uwu)', + 'Current version: ': 'Cuwwent version: ', + 'Unsupported platform!': 'Unsuppowted p-pwatfowm!? :<', + 'Freezer Updates': 'Fweezew Updates', + 'Update to latest version in the settings.': + 'Update t-to watest vewsion in the settings.', + 'Release date': 'W-Wewease date', + 'Shows': 'Shows', + 'Charts': 'Chawts', + 'Browse': 'Bwowse', + 'Quick access': 'Quick access', + 'Play mix': 'Pway mix', + 'Share show': 'Shawe show', + 'Date added': 'Date added', + 'Discord': 'discowd (join to talk to me owo)', + 'Official Discord server': "Officiaw Discowd servew (i'm waiting :>)", + 'Restart of app is required to properly log out!': + 'Westawt of a-app is w-wequiwed to p-pwopewwy wog out!', + 'Artist separator': 'Awtist sepawatow', + 'Singleton naming': 'Singweton n-naming', + 'Keep the screen on': 'Keep the scween o-on', + 'Wakelock enabled!': 'Wakewock enabwed〜☆! ', + 'Wakelock disabled!': 'Wakewock disabwed *:・゚✧*:・゚✧!', + 'Show all shows': 'Show all shows oWo', + 'Episodes': 'Episodes uwu daddy', + 'Show all episodes': 'S-show all episodes >.<', + 'Album cover resolution': 'A-awbum cover wesowution', + "WARNING: Resolutions above 1200 aren't officially supported": + "W-wawning: wesowutions above 1200 awen't officiawwy suppowted", + 'Album removed from library!': 'Awbum wemuvd fwom libwawy *^*', + 'Remove offline': 'Wemuv offwinye', + 'Playlist removed from library!': 'pwaywist wemuvd fwom libwawy *^*', + 'Blur player background': 'Bluw pwayew backgwound', + 'Might have impact on performance': 'Migwt hawve impact on pewfowmance', + 'Font': 'Font', + 'Select font': 'Sewect font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "This app isn't made for suppowting many fonts, it can bweak wayouts and ovewflow. Use at youw own wisk!", + 'Enable equalizer': 'Enable equawizew', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Migwt enable some equawizew apps to wowk. Wequiwes westawt of Fweezer', + 'Visualizer': 'Visuawizew', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Show visuawizews on wywics page. WAWNING: Wequiwes micwophone pewmission!', + 'Tags': 'Tawgs', + 'Album': 'Awbum', + 'Track number': 'Twack nyumbew', + 'Disc number': 'Disk nyumbew', + 'Album artist': 'Awbum awtist', + 'Date/Year': 'Date/Yeaw', + 'Label': 'Wabew', + 'ISRC': 'ISWC', + 'UPC': 'UPC', + 'Track total': 'Twack totaw', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Unsynchwonized wywics', + 'Genre': 'Genwe', + 'Contributors': 'Contwibutows', + 'Album art': 'Awbum awt', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'fil_ph': { + 'Home': 'Tahanan', + 'Search': 'Hanapin', + 'Library': 'Aklatan', + "Offline mode, can't play flow or smart track lists.": + 'Naka-offline mode, hindi ka pwedeng mag-play ng flow o mga smart track.', + 'Added to library': 'Idinagdag na sa library', + 'Download': 'I-download', + 'Disk': 'Disk', + 'Offline': 'Walang koneksyon', + 'Top Tracks': 'Mga Nangungunang Track', + 'Show more tracks': 'Ipakita ang iba pang mga track', + 'Top': 'Nangunguna', + 'Top Albums': 'Nangungunang mga Album', + 'Show all albums': 'Ipakita lahat ng album', + 'Discography': 'Discography', + 'Default': 'I-Default', + 'Reverse': 'Pabaliktad', + 'Alphabetic': 'Alpabetik', + 'Artist': 'Artista', + 'Post processing...': 'Tinatapos na ang proseso...', + 'Done': 'Tapos', + 'Delete': 'Burahin', + 'Are you sure you want to delete this download?': + 'Sigurado ka bang buburahin mo ang iyong dinownload?', + 'Cancel': 'I-cancel', + 'Downloads': 'Mga Download', + 'Clear queue': 'I-clear ang queue', + "This won't delete currently downloading item": + 'Hindi nito buburahin ang dina-download mo ngayon', + 'Are you sure you want to delete all queued downloads?': + 'Sigurado ka bang buburahin lahat ang mga dina-download?', + 'Clear downloads history': 'I-clear ang history ng mga download', + 'WARNING: This will only clear non-offline (external downloads)': + 'BABALA: Buburahin lang nito ang hindi pang-offline (mga eksternal na download)', + 'Please check your connection and try again later...': + 'I-check ang iyong koneksiyon at maaaring subukan mo ulit mamaya...', + 'Show more': 'Ipakita ang iba', + 'Importer': 'Taga-import', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Suportado lang ang Spotify sa ngayon, na may limit sa 100 mga track', + 'Due to API limitations': 'Dahil sa limitasyon ng API', + 'Enter your playlist link below': + 'Ilagay ang link ng iyong playlist sa ibaba', + 'Error loading URL!': 'Nagkaroon ng problema sa pagload URL!', + 'Convert': 'I-convert', + 'Download only': 'I-download lang', + 'Downloading is currently stopped, click here to resume.': + 'Huminto ang download mo, mag-click dito para ituloy', + 'Tracks': 'Mga Track', + 'Albums': 'Mga Album', + 'Artists': 'Mga Artist', + 'Playlists': 'Mga Playlist', + 'Import': 'I-import', + 'Import playlists from Spotify': + 'I-import ang mga playlist galing sa Spotify', + 'Statistics': 'Mga istatistika', + 'Offline tracks': 'Mga offline na track', + 'Offline albums': 'Mga offline na album', + 'Offline playlists': 'Mga offline na playlist', + 'Offline size': 'Laki ng offline', + 'Free space': 'Natitirang space', + 'Loved tracks': 'Pinusuang mga track', + 'Favorites': 'Mga paborito', + 'All offline tracks': 'Lahat ng track na pang-offline', + 'Create new playlist': 'Gumawa ng bagong playlist', + 'Cannot create playlists in offline mode': + 'Hindi makagagawa ng playlist habang naka-offline mode', + 'Error': 'Kamalian', + 'Error logging in! Please check your token and internet connection and try again.': + 'Hindi maka-login! I-check ang iyong token at koneksiyon at ulitin mo.', + 'Dismiss': 'I-dismiss', + 'Welcome to': 'Welcome sa', + 'Please login using your Deezer account.': + 'Paki-login ang iyong Deezer account', + 'Login using browser': 'Mag-login gamit ng browser', + 'Login using token': 'Mag-login gamit ng token', + 'Enter ARL': 'Pakilagay ang ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'I-save', + "If you don't have account, you can register on deezer.com for free.": + 'Kung wala kang account, pumunta sa deezer.com para sa libreng pag-register.', + 'Open in browser': 'Buksan sa browser', + "By using this app, you don't agree with the Deezer ToS": + 'Sa pag-gamit nitong app, ikaw ay hindi sumusunod sa Deezer ToS', + 'Play next': 'I-play ang kasunod', + 'Add to queue': 'Idagdag sa queue', + 'Add track to favorites': 'Idagdag ang track sa mga paborito', + 'Add to playlist': 'Idagdag sa playlist', + 'Select playlist': 'Piliin ang playlist', + 'Track added to': 'Idinagdag ang track sa', + 'Remove from playlist': 'Tinanggal sa playlist', + 'Track removed from': 'Tinanggal ang track sa', + 'Remove favorite': 'Tanggalin ang paborito', + 'Track removed from library': 'Tinanggal ang track sa library', + 'Go to': 'Pumunta sa', + 'Make offline': 'Gawing offline', + 'Add to library': 'Idagdag sa library', + 'Remove album': 'Tanggalin ang album', + 'Album removed': 'Tinanggal ang album', + 'Remove from favorites': 'Tanggalin sa mga paborito', + 'Artist removed from library': 'Tinanggal ang artist sa library', + 'Add to favorites': 'Idagdag sa mga paborito', + 'Remove from library': 'Tanggalin sa library', + 'Add playlist to library': 'Idagdag ang playlist sa library', + 'Added playlist to library': 'Idinagdag ang playlist sa library', + 'Make playlist offline': 'Gawing offline ang playlist', + 'Download playlist': 'I-download ang playlist', + 'Create playlist': 'Gumawa ng playlist', + 'Title': 'Pamagat', + 'Description': 'Deskripsiyon', + 'Private': 'Pribado', + 'Collaborative': 'Pagtutulungan', + 'Create': 'Mag-buo', + 'Playlist created!': 'Nagawa na ang playlist!', + 'Playing from:': 'Tumutugtog galing sa:', + 'Queue': 'Pila', + 'Offline search': 'Offline na paghahanap', + 'Search Results': 'Resulta sa Paghahanap', + 'No results!': 'Walang mahanap!', + 'Show all tracks': 'Ipakita lahat ng mga track', + 'Show all playlists': 'Ipakita lahat ng mga playlist', + 'Settings': 'Mga Setting', + 'General': 'Pangkalahatan', + 'Appearance': 'Itsura', + 'Quality': 'Kalidad', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Kasalukuyan', + 'Select theme': 'Piliin ang Tema', + 'Dark': 'Madilim', + 'Black (AMOLED)': 'Maitim (AMOLED)', + 'Deezer (Dark)': 'Deezer (Madilim)', + 'Primary color': 'Pangunahing kulay', + 'Selected color': 'Piniling kulay', + 'Use album art primary color': 'Gamitin ang pangunahing kulay ng album art', + 'Warning: might be buggy': 'Babala: Pwedeng magkaroon ng bug', + 'Mobile streaming': 'Pag-stream sa mobile', + 'Wifi streaming': 'Pag-stream sa Wifi', + 'External downloads': 'Eksternal na download', + 'Content language': 'Wika ng nilalaman', + 'Not app language, used in headers. Now': + 'gagamitin lang ang wika sa header, hindi sa app. Ngayon', + 'Select language': 'Piliin ang wika', + 'Content country': 'Bansa ng nilalaman', + 'Country used in headers. Now': 'Gagamitin ang bansa sa mga header. Ngayon', + 'Log tracks': 'Log ng mga track', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Ipadala ang log ng mga napakinggang track sa Deezer, I-enable mo para gumana nang maayos sa mga feature kagaya ng Flow', + 'Offline mode': 'Naka Offline mode', + 'Will be overwritten on start.': 'Papatungan sa simula pa lang.', + 'Error logging in, check your internet connections.': + 'Hindi maka-login, Pakicheck ang iyong internet connection.', + 'Logging in...': 'Nagla-login...', + 'Download path': 'Paglalagyan ng download', + 'Downloads naming': 'Pagpapangalan sa mga download', + 'Downloaded tracks filename': 'Filename ng mga nadownload na track', + 'Valid variables are': 'Ang mga pwede lang gamitin ay', + 'Reset': 'I-reset', + 'Clear': 'I-clear', + 'Create folders for artist': 'Gumawa ng folder para sa mga artist', + 'Create folders for albums': 'Gumawa ng folder para sa mga album', + 'Separate albums by discs': 'Ihiwalay ang mga album batay sa disk', + 'Overwrite already downloaded files': 'Patungan ang mga nadownload na file', + 'Copy ARL': 'Kopyahin ang ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Kopyahin ang userToken/ARL Cookie para gamitin sa iba pang app.', + 'Copied': 'Nakopya na', + 'Log out': 'Mag-Log out', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Hindi ka makakapag-login gamit ng browser kung hindi mo ito ire-restart dahil hindi pa compatible ang plugin sa ngayon', + '(ARL ONLY) Continue': '(ARL LANG) Ituloy', + 'Log out & Exit': 'Mag-Log out at Lumabas', + 'Pick-a-Path': 'Pumili-ng-Path', + 'Select storage': 'Piliin ang storage', + 'Go up': 'Pumunta paitaas', + 'Permission denied': 'Hindi pinapayagan', + 'Language': 'Wika', + 'Language changed, please restart ReFreezer to apply!': + 'Pinalitan ang wika, paki-restart ang Deezer para mai-apply!', + 'Importing...': 'Ini-import...', + 'Radio': 'Radyo', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Ang kanta ay wala sa Deezer!', + 'Failed to download track! Please restart.': + 'Hindi na-download ang kanta! Paki-ulit.', + 'Storage permission denied!': 'Tinaggihan ang paghihintulot sa Storage', + 'Failed': 'Nabigo', + 'Queued': 'Naka-queue', + 'External': 'Storage', + 'Restart failed downloads': 'Ulitin ang hindi na-download', + 'Clear failed': 'Pagtanggal ay hindi gumana', + 'Download Settings': 'I-download ang settings', + 'Create folder for playlist': 'Gumawa ng folder sa mga playlist', + 'Download .LRC lyrics': 'I-download ang .LRC lyrics', + 'Proxy': 'Proxy', + 'Not set': 'Hindi naka-set', + 'Search or paste URL': 'Ilagay ang url', + 'History': 'Kasaysayan', + 'Download threads': 'Magkakasabay na downloads', + 'Lyrics unavailable, empty or failed to load!': + 'Liriko ay hindi mahanap, blanko o hindi nag load!', + 'About': 'Tungkol sa app', + 'Telegram Channel': 'Channel ng Telegram', + 'To get latest releases': 'Para makuha ang pinakabagong releases', + 'Official chat': 'Opisyal na chat', + 'Telegram Group': 'Grupo sa Telegram', + 'Huge thanks to all the contributors! <3': + 'Maraming salamat sa mga tumulong! <3', + 'Edit playlist': 'I-edit ang playlist', + 'Update': 'I-update', + 'Playlist updated!': 'Playlist ay binago!', + 'Downloads added!': 'Dinagdag ang downloads!', + 'Save cover file for every track': + "Ilagay ang civer track sa iba't-ibang track", + 'Download Log': 'Ang download log', + 'Repository': 'Repositoryo', + 'Source code, report issues there.': + 'Ang source code, i-report and isyu doon.', + 'Use system theme': 'Gamitin ang tema ng sistema', + 'Light': 'Liwanag', + 'Popularity': 'Kasikatan', + 'User': 'Ang Gumagamit', + 'Track count': 'Bilang ng kanta', + "If you want to use custom directory naming - use '/' as directory separator.": + "Kung gustong gumamit ng pansariling pangalan ng directory, gamitin ang '/' bilang directory separator.", + 'Share': 'Ibahagi', + 'Save album cover': 'I-save ang album cover', + 'Warning': 'Babala', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Ang paggamit ng masyadong madaming concurrent downloads sa mahina/lumang devices ay maaaring magdulot ng crashes!', + 'Create .nomedia files': 'Gumawa ng .nomedia files', + 'To prevent gallery being filled with album art': + 'Upang maiwasan na mapuno ang gallery ng mga album art', + 'Sleep timer': 'Orasan ng pagtigil', + 'Minutes:': 'Minuto:', + 'Hours:': 'Oras:', + 'Cancel current timer': 'Ihinto ang orasan ng pagtigil', + 'Current timer ends at': 'Orasan ng pagtigil ay hihinto sa', + 'Smart track list': 'Matalinong track list', + 'Shuffle': 'Paghaluin', + 'Library shuffle': 'Paghaluin ang library', + 'Ignore interruptions': 'Huwag pansinin ang pagkaabala', + 'Requires app restart to apply!': + 'Kailangang i-restart ang app upang gumana!', + 'Ask before downloading': 'Itanong muna bago i-download', + 'Search history': 'Saliksikin ang Kasaysayan', + 'Clear search history': 'Burahin ang search history', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Mag-login para paganahin ang scrobbling.', + 'Login to LastFM': 'Mag-login gamit ang LastFM', + 'Username': 'Pangalan ng gumagamit', + 'Password': 'Password', + 'Login': 'Mag-login', + 'Authorization error!': 'Bigo ang pag login!', + 'Logged out!': 'Nag logout!', + 'Lyrics': 'Mga liriko', + 'Player gradient background': 'Player gradient background', + 'Updates': 'Mga update', + 'You are running latest version!': + 'Ang iyong gamit ay ang pinakabagong bersyon!', + 'New update available!': 'May bagong update na!', + 'Current version: ': 'Kasalukuyang bersyon: ', + 'Unsupported platform!': 'Hindi suportadong plataporma!', + 'Freezer Updates': 'Mga update ng Freezer', + 'Update to latest version in the settings.': + 'Mag-update sa pinakabagong bersyon sa settings.', + 'Release date': 'Petsa ng paglabas', + 'Shows': 'Shows', + 'Charts': 'Charts', + 'Browse': 'Mag-browse', + 'Quick access': 'Quick access', + 'Play mix': 'Play mix', + 'Share show': 'Share show', + 'Date added': 'Date added', + 'Discord': 'Discord', + 'Official Discord server': 'Official Discord server', + 'Restart of app is required to properly log out!': + 'i-restart ang app, para maka-log out ng maayos!', + 'Artist separator': 'Artist separator', + 'Singleton naming': 'Singleton naming', + 'Keep the screen on': 'Panatilihing nakabukas ang screen', + 'Wakelock enabled!': 'Naka-enable ang wakelock!', + 'Wakelock disabled!': 'Naka-disable ang wakelock!', + 'Show all shows': 'Ipakita ang lahat ng palabas', + 'Episodes': 'Mga episode', + 'Show all episodes': 'Ipakita ang lahat ng mga episode', + 'Album cover resolution': 'Album cover resolution', + "WARNING: Resolutions above 1200 aren't officially supported": + 'BABALA: Ang mga resolution na mas mataas sa 1200 ay hindi opisyal na sinusuportahan', + 'Album removed from library!': 'Natanggal ang album sa library!', + 'Remove offline': 'Remove offline', + 'Playlist removed from library!': 'Playlist removed from library!', + 'Blur player background': 'Blur player background', + 'Might have impact on performance': 'Might have impact on performance', + 'Font': 'Font', + 'Select font': 'Select font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!", + 'Enable equalizer': 'Enable equalizer', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Might enable some equalizer apps to work. Requires restart of Freezer', + 'Visualizer': 'Visualizer', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Track number', + 'Disc number': 'Disc number', + 'Album artist': 'Album artist', + 'Date/Year': 'Date/Year', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Track total', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Unsynchronized lyrics', + 'Genre': 'Genre', + 'Contributors': 'Contributors', + 'Album art': 'Album art', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'fr_fr': { + 'Home': 'Accueil', + '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 connexion', + 'Top Tracks': 'Meilleurs titres', + 'Show more tracks': 'Afficher plus de titres', + 'Top': 'Top', + 'Top Albums': 'Meilleurs 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 titres", + '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': 'Titres', + 'Albums': 'Albums', + 'Artists': 'Artistes', + 'Playlists': 'Playlists', + 'Import': 'Importer', + 'Import playlists from Spotify': 'Importer des playlists depuis Spotify', + 'Statistics': 'Statistiques', + 'Offline tracks': 'Titres 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 titres 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': 'Ignorer', + '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': 'Titre ajouté à', + 'Remove from playlist': 'Retirer de la playlist', + 'Track removed from': 'Titre retiré de', + 'Remove favorite': 'Supprimer Coup de cœur ', + 'Track removed from library': 'Titre supprimé 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': 'Collaboratif', + '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 tous les titres', + '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', + '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 titres téléchargés', + 'Valid variables are': 'Les variables valides sont', + 'Reset': 'Réinitialiser', + 'Clear': 'Effacer', + 'Create folders for artist': 'Générer des dossiers par artiste', + 'Create folders for albums': 'Générer 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 ReFreezer to apply!': + 'Langue modifiée, veuillez redémarrer ReFreezer pour que les changements prennent effet!', + 'Importing...': 'Importation...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + "Le titre n'est pas disponible sur Deezer!", + 'Failed to download track! Please restart.': + 'Échec du téléchargement du titre ! Veuillez réessayer.', + 'Storage permission denied!': "Autorisation d'accès au stockage refusée!", + 'Failed': 'Échec', + 'Queued': "Ajouté à la file d'attente", + 'External': 'Stockage', + 'Restart failed downloads': 'Relancer les téléchargements échoués', + 'Clear failed': 'Effacer les téléchargements échoués', + 'Download Settings': 'Paramètres des téléchargements', + 'Create folder for playlist': 'Générer des dossiers par playlist', + 'Download .LRC lyrics': 'Télécharger les fichiers de paroles .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Non défini', + 'Search or paste URL': 'Rechercher ou coller un lien', + 'History': 'Historique', + 'Download threads': 'Téléchargements simultanés', + 'Lyrics unavailable, empty or failed to load!': + 'Paroles indisponibles, vides ou erreur de chargement !', + 'About': 'À propos', + 'Telegram Channel': 'Canal Telegram', + 'To get latest releases': "Pour obtenir les dernières versions de l'app", + 'Official chat': 'Chat officiel', + 'Telegram Group': 'Groupe Telegram', + 'Huge thanks to all the contributors! <3': + 'Un grand merci à tous les contributeurs ! <3', + 'Edit playlist': 'Modifier la playlist', + 'Update': 'Mettre à jour', + 'Playlist updated!': 'Playlist mise à jour !', + 'Downloads added!': 'Téléchargements ajoutés !', + 'Save cover file for every track': + 'Sauvegarder la pochette pour chaque titre', + 'Download Log': 'Journal des téléchargements', + 'Repository': 'Dépôt', + 'Source code, report issues there.': + 'Code source, signaler les problèmes ici.', + 'Use system theme': 'Utiliser le thème du système', + 'Light': 'Clair', + 'Popularity': 'Popularité', + 'User': 'Utilisateur', + 'Track count': 'Nombre de pistes', + "If you want to use custom directory naming - use '/' as directory separator.": + "Si vous souhaitez utiliser un nom de répertoire personnalisé, utilisez '/' comme séparateur.", + 'Share': 'Partager', + 'Save album cover': "Sauvegarder la pochette d'album", + 'Warning': 'Avertissement', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Un trop grand nombre de téléchargements simultanés peut entraîner des plantages sur les appareils anciens ou peu puissants!', + 'Create .nomedia files': 'Générer des fichiers .nomedia', + 'To prevent gallery being filled with album art': + "Afin d'éviter que la galerie ne soit remplie de pochettes d'album", + 'Sleep timer': 'Minuteur sommeil', + 'Minutes:': 'Minutes : ', + 'Hours:': 'Heures : ', + 'Cancel current timer': 'Annuler le minuteur en cours', + 'Current timer ends at': 'Le minuteur actuel se termine à', + 'Smart track list': 'Liste de titres intelligente', + 'Shuffle': 'Aléatoire', + 'Library shuffle': 'Lecture aléatoire de la bibliothèque', + 'Ignore interruptions': 'Ignorer les interruptions', + 'Requires app restart to apply!': + 'Redémarrage requis pour appliquer les changements !', + 'Ask before downloading': 'Demander une confirmation avant de télécharger', + 'Search history': 'Historique de recherche', + 'Clear search history': "Effacer l'historique de recherche", + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Connectez-vous pour activer le scrobbling.', + 'Login to LastFM': 'Connexion à LastFM', + 'Username': "Nom d'utilisateur", + 'Password': 'Mot de passe', + 'Login': 'Connexion', + 'Authorization error!': "Erreur d'autorisation !", + 'Logged out!': 'Déconnecté !', + 'Lyrics': 'Paroles', + 'Player gradient background': 'Arrière-plan du lecteur en dégradé', + 'Updates': 'Mises à jour', + 'You are running latest version!': 'Vous utilisez la dernière version !', + 'New update available!': 'Une nouvelle mise à jour est disponible !', + 'Current version: ': 'Version actuelle :', + 'Unsupported platform!': "Système d'exploitation non pris en charge !", + 'Freezer Updates': 'Mises à jour de Freezer', + 'Update to latest version in the settings.': + 'Mettez à jour vers la dernière version dans les paramètres.', + 'Release date': 'Date de mise en ligne', + 'Shows': 'Shows', + 'Charts': 'Classements', + 'Browse': 'Parcourir', + 'Quick access': 'Accès rapide', + 'Play mix': 'Jouer un mix', + 'Share show': 'Partager un show', + 'Date added': 'Ajouté le', + 'Discord': 'Discord', + 'Official Discord server': 'Serveur officiel Discord', + 'Restart of app is required to properly log out!': + "Le redémarrage de l'application est nécessaire pour se déconnecter correctement !", + 'Artist separator': "Séparateur d'artiste", + 'Singleton naming': 'Dénomination unique', + 'Keep the screen on': "Garder l'écran allumé", + 'Wakelock enabled!': "Garder l'écran allumé : activé !", + 'Wakelock disabled!': "Garder l'écran allumé : désactivé !", + 'Show all shows': 'Afficher tous les shows', + 'Episodes': 'Épisodes', + 'Show all episodes': 'Afficher tous les épisodes', + 'Album cover resolution': "Résolution de la pochette d'album", + "WARNING: Resolutions above 1200 aren't officially supported": + 'AVERTISSEMENT : Les résolutions supérieures à 1200 ne sont pas officiellement supportées', + 'Album removed from library!': 'Album supprimé de la bibliothèque !', + 'Remove offline': 'Supprimer hors-ligne', + 'Playlist removed from library!': 'Playlist supprimée de la bibliothèque !', + 'Blur player background': "Flouter l'arrière-plan du lecteur", + 'Might have impact on performance': + 'Peut avoir un impact sur les performances', + 'Font': 'Police', + 'Select font': 'Sélectionner la police', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "Cette application n'est pas conçue pour prendre en charge autant de polices, des problèmes de mise en page et de débordement peuvent survenir. Utilisez à vos risques et périls!", + 'Enable equalizer': "Activer l'égaliseur", + 'Might enable some equalizer apps to work. Requires restart of Freezer': + "Peut permettre le support de certaines applications d'égalisation sonore. Nécessite un redémarrage de Freezer", + 'Visualizer': 'Visualisation', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + "Afficher les visualisations sur la page des paroles. ATTENTION : Nécessite l'autorisation d'accès au microphone !", + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Numéro de piste', + 'Disc number': 'Numéro du disque', + 'Album artist': "Artiste de l'album", + 'Date/Year': 'Date/Année', + 'Label': 'Maison de disque', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Nombre de pistes', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Paroles non synchronisées', + 'Genre': 'Genre', + 'Contributors': 'Contributeurs', + 'Album art': "Pochette d'album", + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + "Deezer n'est pas disponible dans votre pays, ReFreezer pourrait ne pas fonctionner correctement. Veuillez utiliser un VPN", + 'Deezer is unavailable': "Freezer n'est pas disponible", + 'Continue': 'Continuer', + 'Email Login': 'Connexion par email', + 'Email': 'Email', + 'Missing email or password!': 'Email ou mot de passe manquant !', + 'Error logging in using email, please check your credentials.\nError:': + "Erreur lors de la connexion avec l'email, veuillez vérifier vos identifiants.\nErreur :", + 'Error logging in!': 'Erreur de connexion !', + 'Change display mode': "Changer le mode d'affichage", + 'Enable high refresh rates': 'Activer les taux de rafraîchissement élevés', + 'Display mode': "Mode d'affichage", + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + "Importer des playlists Spotify jusqu'à 100 titres sans connexion.", + 'Download imported tracks': 'Télécharger les titres importés', + 'Start import': "Démarrer l'importation", + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + "Importer n'importe quelle playlist Spotify, importer depuis sa propre bibliothèque Spotify. Nécessite un compte gratuit.", + 'Spotify Importer v2': 'Importation Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + "Cette importation nécessite l'ID client Spotify et le Secret Client. Pour les obtenir :", + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Rendez-vous sur : developer.spotify.com/dashboard et créez une application.', + 'Open in Browser': 'Ouvrir dans le navigateur', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + "2. Dans l'application que vous venez de créer, allez dans les paramètres et définissez l'URL de redirection à : ", + 'Copy the Redirect URL': "Copier l'URL de redirection", + 'Client ID': 'ID Client', + 'Client Secret': 'Secret Client', + 'Authorize': 'Autoriser', + 'Logged in as: ': 'Connecté en tant que : ', + 'Import playlists by URL': 'Importer des playlists par URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'URL invalide/non supportée', + 'Please wait...': 'Veuillez patienter...', + 'Login using email': 'Connexion par Email', + 'Track removed from offline!': + 'Le titre a été retiré du mode hors connexion !', + 'Removed album from offline!': + "L'album a été retiré du mode hors connexion !", + 'Playlist removed from offline!': + 'La playlist a été retirée du mode hors connexion !', + 'Repeat': 'Répéter', + 'Repeat one': 'Répéter une fois', + 'Repeat off': 'Répétition désactivée', + 'Love': 'Aimer', + 'Unlove': "Je n'aime pas", + 'Dislike': 'Je déteste', + 'Close': 'Fermer', + 'Sort playlist': 'Trier la playlist', + 'Sort ascending': 'Tri croissant', + 'Sort descending': 'Tri décroissant', + 'Stop': 'Arrêter', + 'Start': 'Démarrer', + 'Clear all': 'Effacer tout', + 'Play previous': 'Lire le précédent', + 'Play': 'Lire', + 'Pause': 'Pause', + 'Remove': 'Supprimer', + 'Seekbar': 'Barre de recherche', + 'Singles': 'Singles', + 'Featured': 'À la une', + 'Fans': 'Fans', + 'Duration': 'Durée', + 'Sort': 'Tri', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Votre ARL a peut-être expiré, essayez de vous déconnecter et de vous reconnecter en utilisant un nouvel ARL ou un autre navigateur.' + }, + 'de_de': { + 'Home': 'Startseite', + 'Search': 'Suche', + 'Library': 'Bibliothek', + "Offline mode, can't play flow or smart track lists.": + 'Offline-Modus, kann keine Flow- oder Smart Track-Listen abspielen.', + 'Added to library': 'Zur Mediathek hinzugefügt', + 'Download': 'Download', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Top Titel', + 'Show more tracks': 'Zeige mehr Titel', + 'Top': 'Top', + 'Top Albums': 'Top Alben', + 'Show all albums': 'Zeige alle Alben', + 'Discography': 'Diskografie', + 'Default': 'Standard', + 'Reverse': 'Rückwärts', + 'Alphabetic': 'Alphabetisch', + 'Artist': 'Künstler', + 'Post processing...': 'Nachbearbeitung...', + 'Done': 'Fertig', + 'Delete': 'Löschen', + 'Are you sure you want to delete this download?': + 'Bist du sicher, dass du diesen Download löschen willst?', + 'Cancel': 'Abbrechen', + 'Downloads': 'Downloads', + 'Clear queue': 'Warteschleife leeren', + "This won't delete currently downloading item": + 'Dies löscht das derzeit heruntergeladene Element nicht', + 'Are you sure you want to delete all queued downloads?': + 'Bist du sicher, dass du alle Downloads aus der Warteschleife entfernen möchtest?', + 'Clear downloads history': 'Download-Verlauf löschen', + 'WARNING: This will only clear non-offline (external downloads)': + 'ACHTUNG: (Externe Downloads) werden entfernt', + 'Please check your connection and try again later...': + 'Bitte überprüfe deine Verbindung und versuche es später noch einmal...', + 'Show more': 'Mehr anzeigen', + 'Importer': 'Importierer', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Derzeit wird nur Spotify Unterstützt, begrenzt auf maximal 100 Titel', + 'Due to API limitations': 'Aufgrund von API-Einschränkungen', + 'Enter your playlist link below': 'Gib deinen Playlist-Link unten ein', + 'Error loading URL!': 'Fehler beim Laden der URL!', + 'Convert': 'Konvertieren', + 'Download only': 'Nur Herunterladen', + 'Downloading is currently stopped, click here to resume.': + 'Das Herunterladen ist derzeit gestoppt, klicke hier, um fortzufahren.', + 'Tracks': 'Titel', + 'Albums': 'Alben', + 'Artists': 'Künstler', + 'Playlists': 'Playlists', + 'Import': 'Importieren', + 'Import playlists from Spotify': 'Playlists aus Spotify importieren', + 'Statistics': 'Statistiken', + 'Offline tracks': 'Offline-Titel', + 'Offline albums': 'Offline-Alben', + 'Offline playlists': 'Offline-Playlists', + 'Offline size': 'Offline-Größe', + 'Free space': 'Freier Speicherplatz', + 'Loved tracks': 'Beliebte Titel', + 'Favorites': 'Favoriten', + 'All offline tracks': 'Alle Offline-Titel', + 'Create new playlist': 'Neue Playlist erstellen', + 'Cannot create playlists in offline mode': + 'Playlists können im Offline-Modus nicht erstellt werden', + 'Error': 'Fehler', + 'Error logging in! Please check your token and internet connection and try again.': + 'Fehler beim Einloggen! Bitte überprüfe dein Token und deine Internetverbindung und versuche es erneut.', + 'Dismiss': 'Verwerfen', + 'Welcome to': 'Willkommen bei', + 'Please login using your Deezer account.': + 'Bitte melde dich mit deinem Deezer-Konto an.', + 'Login using browser': 'Anmeldung über Browser', + 'Login using token': 'Anmeldung per Token', + 'Enter ARL': 'ARL eingeben', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Speichern', + "If you don't have account, you can register on deezer.com for free.": + 'Wenn Du noch kein Konto hast, kannst Du Dich kostenlos auf deezer.com registrieren.', + 'Open in browser': 'Im Browser öffnen', + "By using this app, you don't agree with the Deezer ToS": + 'Durch die Verwendung dieser App lehnst du die Nutzungsbedingungen von Deezer ab', + 'Play next': 'Als nächstes abspielen', + 'Add to queue': 'Zur Warteschleife hinzufügen', + 'Add track to favorites': 'Titel zu Favoriten hinzufügen', + 'Add to playlist': 'Zur Playlist hinzufügen', + 'Select playlist': 'Playlist auswählen', + 'Track added to': 'Titel hinzugefügt zu', + 'Remove from playlist': 'Aus Playlist entfernen', + 'Track removed from': 'Titel entfernt aus', + 'Remove favorite': 'Favorit entfernen', + 'Track removed from library': 'Titel aus Mediathek entfernt', + 'Go to': 'Gehe zu', + 'Make offline': 'Offline verfügbar machen', + 'Add to library': 'Zur Mediathek hinzufügen', + 'Remove album': 'Album entfernen', + 'Album removed': 'Album entfernt', + 'Remove from favorites': 'Aus Favoriten entfernen', + 'Artist removed from library': 'Künstler aus Bibliothek entfernt', + 'Add to favorites': 'Zu Favoriten hinzufügen', + 'Remove from library': 'Aus der Mediathek entfernen', + 'Add playlist to library': 'Playlist zur Mediathek hinzufügen', + 'Added playlist to library': 'Playlist zur Mediathek hinzugefügt', + 'Make playlist offline': 'Playlist offline verfügbar machen', + 'Download playlist': 'Playlist herunterladen', + 'Create playlist': 'Playlist erstellen', + 'Title': 'Titel', + 'Description': 'Beschreibung', + 'Private': 'Privat', + 'Collaborative': 'Gemeinsam', + 'Create': 'Erstellen', + 'Playlist created!': 'Playlist erstellt!', + 'Playing from:': 'Wiedergabe von:', + 'Queue': 'Warteschleife', + 'Offline search': 'Offline-Suche', + 'Search Results': 'Suchergebnisse', + 'No results!': 'Keine Ergebnisse!', + 'Show all tracks': 'Alle Titel anzeigen', + 'Show all playlists': 'Alle Playlists anzeigen', + 'Settings': 'Einstellungen', + 'General': 'Allgemein', + 'Appearance': 'Aussehen', + 'Quality': 'Qualität', + 'Deezer': 'Deezer', + 'Theme': 'Erscheinungsbild', + 'Currently': 'Aktuell', + 'Select theme': 'Erscheinungsbild auswählen', + 'Dark': 'Dunkel', + 'Black (AMOLED)': 'Schwarz (AMOLED)', + 'Deezer (Dark)': 'Deezer (Dunkel)', + 'Primary color': 'Primärfarbe', + 'Selected color': 'Ausgewählte Farbe', + 'Use album art primary color': 'Verwende die Primärfarbe des Albumcovers', + 'Warning: might be buggy': 'Warnung: könnte fehlerhaft sein', + 'Mobile streaming': 'Wiedergabe über Mobilfunknetz', + 'Wifi streaming': 'Wiedergabe über WLAN', + 'External downloads': 'Externe Downloads', + 'Content language': 'Sprache des Inhalts', + 'Not app language, used in headers. Now': 'Aktuell', + 'Select language': 'Sprache auswählen', + 'Content country': 'Land des Inhalts', + 'Country used in headers. Now': 'Aktuell', + 'Log tracks': 'Protokolliere Titel', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Gehörte Titel-Protokolle an Deezer senden, damit Flow richtig funktioniert', + 'Offline mode': 'Offline-Modus', + 'Will be overwritten on start.': 'Wird beim Start überschrieben.', + 'Error logging in, check your internet connections.': + 'Fehler beim Anmelden, überprüfe deine Internetverbindung.', + 'Logging in...': 'Anmelden...', + 'Download path': 'Download-Pfad', + 'Downloads naming': 'Benennung der Downloads', + 'Downloaded tracks filename': 'Dateiname der heruntergeladenen Titel', + 'Valid variables are': 'Gültige Variablen sind', + 'Reset': 'Zurücksetzen', + 'Clear': 'Löschen', + 'Create folders for artist': 'Ordner für Künstler erstellen', + 'Create folders for albums': 'Ordner für Alben erstellen', + 'Separate albums by discs': 'Alben nach Discs trennen', + 'Overwrite already downloaded files': + 'Bereits heruntergeladene Dateien überschreiben', + 'Copy ARL': 'ARL kopieren', + 'Copy userToken/ARL Cookie for use in other apps.': + 'UserToken / ARL-Cookie zur Verwendung in anderen Anwendungen kopieren.', + 'Copied': 'Kopiert', + 'Log out': 'Abmelden', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Aufgrund einer Plugin-Inkompatibilität ist die Anmeldung mit dem Browser ohne Neustart nicht möglich.', + '(ARL ONLY) Continue': '(NUR ARL) Fortfahren', + 'Log out & Exit': 'Abmelden & Beenden', + 'Pick-a-Path': 'Wähle einen Pfad', + 'Select storage': 'Verzeichnis auswählen', + 'Go up': 'Nach oben', + 'Permission denied': 'Zugriff verweigert', + 'Language': 'Sprache', + 'Language changed, please restart ReFreezer to apply!': + 'Sprache geändert, bitte ReFreezer neu starten!', + 'Importing...': 'Importiere...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + 'Titel ist bei Deezer nicht verfügbar!', + 'Failed to download track! Please restart.': + 'Download des Tracks fehlgeschlagen! Bitte neustarten.', + 'Storage permission denied!': 'Speicherzugriff verweigert!', + 'Failed': 'Fehlgeschlagen', + 'Queued': 'In der Warteschleife', + 'External': 'Speicherplatz', + 'Restart failed downloads': 'Fehlgeschlagene Downloads neu starten', + 'Clear failed': 'Fehlgeschlagene Downloads löschen', + 'Download Settings': 'Download-Einstellungen', + 'Create folder for playlist': 'Ordner für Playlist erstellen', + 'Download .LRC lyrics': '.LRC-Lyrics herunterladen', + 'Proxy': 'Proxy', + 'Not set': 'Nicht festgelegt', + 'Search or paste URL': 'Suchen oder URL einfügen', + 'History': 'Verlauf', + 'Download threads': 'Gleichzeitige Downloads', + 'Lyrics unavailable, empty or failed to load!': + 'Lyrics nicht verfügbar, leer oder laden fehlgeschlagen!', + 'About': 'Über', + 'Telegram Channel': 'Telegram-Kanal', + 'To get latest releases': 'Um die neuesten Versionen zu erhalten', + 'Official chat': 'Offizieller Chat', + 'Telegram Group': 'Telegram-Gruppe', + 'Huge thanks to all the contributors! <3': + 'Großer Dank an alle Mitwirkenden! <3', + 'Edit playlist': 'Playlist bearbeiten', + 'Update': 'Update', + 'Playlist updated!': 'Playlist aktualisiert!', + 'Downloads added!': 'Downloads hinzugefügt!', + 'Save cover file for every track': 'Albumcover für jeden Titel speichern', + 'Download Log': 'Download-Log', + 'Repository': 'Quelle', + 'Source code, report issues there.': 'Quellcode, Probleme dort melden.', + 'Use system theme': 'Systemvorgabe benutzen', + 'Light': 'Heller Modus', + 'Popularity': 'Beliebtheit', + 'User': 'Benutzer', + 'Track count': 'Anzahl der Titel', + "If you want to use custom directory naming - use '/' as directory separator.": + "Wenn du eine benutzerdefinierte Verzeichnisbenennung verwenden möchtest - verwende '/' als Verzeichnistrennzeichen.", + 'Share': 'Teilen', + 'Save album cover': 'Albumcover speichern', + 'Warning': 'Warnung', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Zu viele gleichzeitige Downloads auf älteren oder schwächeren Geräten können zu Abstürzen führen!', + 'Create .nomedia files': '.nomedia Dateien erstellen', + 'To prevent gallery being filled with album art': + 'Um zu verhindern, dass die Galerie mit Albumcovern gefüllt wird', + 'Sleep timer': 'Schlummerfunktion', + 'Minutes:': 'Minuten:', + 'Hours:': 'Stunden:', + 'Cancel current timer': 'Aktuellen Timer abbrechen', + 'Current timer ends at': 'Der aktuelle Timer endet um', + 'Smart track list': 'Intelligente Track-Liste', + 'Shuffle': 'Zufällige Wiedergabe', + 'Library shuffle': 'Zufällige Mediathek-Wiedergabe', + 'Ignore interruptions': 'Unterbrechungen ignorieren', + 'Requires app restart to apply!': 'Erfordert einen Neustart der App!', + 'Ask before downloading': 'Vor dem Download fragen', + 'Search history': 'Suchverlauf', + 'Clear search history': 'Suchverlauf löschen', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Anmelden, um Scrobbling zu aktivieren.', + 'Login to LastFM': 'Bei LastFM anmelden', + 'Username': 'Benutzername', + 'Password': 'Passwort', + 'Login': 'Anmelden', + 'Authorization error!': 'Autorisierungsfehler!', + 'Logged out!': 'Abgemeldet!', + 'Lyrics': 'Lyrics', + 'Player gradient background': 'Verlaufshintergrund des Players', + 'Updates': 'Updates', + 'You are running latest version!': 'Du benutzt die neueste Version!', + 'New update available!': 'Neues Update verfügbar!', + 'Current version: ': 'Aktuelle Version: ', + 'Unsupported platform!': 'Nicht unterstütztes Betriebssystem!', + 'Freezer Updates': 'Freezer Updates', + 'Update to latest version in the settings.': + 'Auf die neueste Version in den Einstellungen aktualisieren.', + 'Release date': 'Veröffentlichungsdatum', + 'Shows': 'Shows', + 'Charts': 'Charts', + 'Browse': 'Durchsuchen', + 'Quick access': 'Schnellzugriff', + 'Play mix': 'Mix abspielen', + 'Share show': 'Show teilen', + 'Date added': 'Datum hinzugefügt', + 'Discord': 'Discord', + 'Official Discord server': 'Offizieller Discord-Server', + 'Restart of app is required to properly log out!': + 'Neustart der App ist erforderlich, um sich richtig abzumelden!', + 'Artist separator': 'Künstler-Trennzeichen', + 'Singleton naming': 'Singleton-Namen', + 'Keep the screen on': 'Bildschirm eingeschaltet lassen', + 'Wakelock enabled!': 'Wakelock aktiviert!', + 'Wakelock disabled!': 'Wakelock deaktiviert!', + 'Show all shows': 'Alle Shows anzeigen', + 'Episodes': 'Episoden', + 'Show all episodes': 'Alle Episoden anzeigen', + 'Album cover resolution': 'Auflösung des Albumcovers', + "WARNING: Resolutions above 1200 aren't officially supported": + 'WARNUNG: Auflösungen über 1200 werden offiziell nicht unterstützt', + 'Album removed from library!': 'Album aus Mediathek entfernt!', + 'Remove offline': 'Offline entfernen', + 'Playlist removed from library!': 'Playlist aus der Mediathek entfernt!', + 'Blur player background': 'Unscharfer Player-Hintergrund', + 'Might have impact on performance': + 'Könnte Einfluss auf die Performance haben', + 'Font': 'Schriftart', + 'Select font': 'Schriftart auswählen', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Diese App unterstützt nicht viele Schriftarten, sie kann Layouts beschädigen und Überläufe verursachen. Benutzung auf eigene Gefahr!', + 'Enable equalizer': 'Equalizer aktivieren', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Könnte einige Equalizer-Apps aktivieren, um zu funktionieren. Benötigt einen Neustart der Freezer-App', + 'Visualizer': 'Visualisierung', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Zeige Visualisierungen auf der Songtext-Seite. WARNUNG: Benötigt Mikrofon-Berechtigung!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Titelnummer', + 'Disc number': 'Disk-Nummer', + 'Album artist': 'Albuminterpret', + 'Date/Year': 'Datum/Jahr', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Track gesamt', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Unsynchronisierte Songtexte', + 'Genre': 'Genre', + 'Contributors': 'Mitwirkende', + 'Album art': 'Album-Cover', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer ist in deinem Land nicht verfügbar, ReFreezer funktioniert möglicherweise nicht richtig. Bitte benutzen Sie ein VPN', + 'Deezer is unavailable': 'Deezer ist nicht verfügbar', + 'Continue': 'Weiter', + 'Email Login': 'E-Mail - Login', + 'Email': 'E-Mail', + 'Missing email or password!': 'E-Mail oder Passwort fehlen!', + 'Error logging in using email, please check your credentials.\nError:': + 'Fehler beim Anmelden per E-Mail, bitte überprüfen Sie Ihre Anmeldeinformationen.\nFehler:', + 'Error logging in!': 'Fehler beim Anmelden!', + 'Change display mode': 'Anzeigemodus wechseln', + 'Enable high refresh rates': 'Hohe Aktualisierungsraten aktivieren', + 'Display mode': 'Anzeigemodus', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importieren Sie Spotify-Wiedergabelisten bis zu 100 Tracks ohne Anmeldung.', + 'Download imported tracks': 'Importierte Titel herunterladen', + 'Start import': 'Import beginnen', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Importieren Sie jede Spotify-Wiedergabeliste, importieren Sie aus der eigenen Spotify-Bibliothek. Benötigt ein kostenloses Konto.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Dieser Importer benötigt Spotify Client ID und Client Secret. Um diese zu erhalten:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Gehen Sie zu: developer.spotify.com/dashboard und erstellen Sie eine App.', + 'Open in Browser': 'Im Browser öffnen', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In der App, die Sie gerade erstellt haben, gehen Sie zu den Einstellungen und setzen Sie die Umleitungs-URL auf: ', + 'Copy the Redirect URL': 'Weiterleitungs-URL kopieren', + 'Client ID': 'Client-ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Autorisieren', + 'Logged in as: ': 'Angemeldet als: ', + 'Import playlists by URL': 'Playlisten per URL importieren', + 'URL': 'URL', + 'Options': 'Optionen', + 'Invalid/Unsupported URL': 'Ungültig/Nicht unterstützte URL', + 'Please wait...': 'Bitte warten...', + 'Login using email': 'Mit E-Mail anmelden', + 'Track removed from offline!': 'Track wurde aus Offline entfernt!', + 'Removed album from offline!': 'Album wurde aus Offline entfernt!', + 'Playlist removed from offline!': 'Playlist wurde aus Offline entfernt!', + 'Repeat': 'Wiederholen', + 'Repeat one': 'Aktuellen Titel wiederholen', + 'Repeat off': 'Wiederholen deaktivieren', + 'Love': 'Liebe', + 'Unlove': 'Ungeliebt', + 'Dislike': 'Gefällt mir nicht', + 'Close': 'Schließen', + 'Sort playlist': 'Wiedergabelisten sortieren', + 'Sort ascending': 'Aufsteigend sortieren', + 'Sort descending': 'Absteigend sortieren', + 'Stop': 'Stopp', + 'Start': 'Start', + 'Clear all': 'Alle Löschen', + 'Play previous': 'Vorheriges abspielen', + 'Play': 'Wiedergeben', + 'Pause': 'Pause', + 'Remove': 'Entfernen', + 'Seekbar': 'Suchleiste', + 'Singles': 'Singles', + 'Featured': 'Vorgestellte', + 'Fans': 'Fans', + 'Duration': 'Dauer', + 'Sort': 'Sortieren', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Ihre ARL könnte abgelaufen sein, versuchen Sie sich auszuloggen und sich mit einer neuer ARL oder dem Browser wieder einzuloggen.' + }, + 'el_gr': { + '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.': + 'Σφάλμα σύνδεσης! Ελέγξτε το token και τη σύνδεσή σας στο δίκτυο και δοκιμάστε ξανά.', + 'Dismiss': 'Απόρριψη', + 'Welcome to': 'Καλωσήρθατε στο', + 'Please login using your Deezer account.': + 'Συνδεθείτε χρησιμοποιώντας τον λογαριασμό σας στο 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.', + 'Open in browser': 'Ανοιγμα σε πρόγραμμα περιήγησης', + "By using this app, you don't agree with the Deezer ToS": + 'Χρησιμοποιώντας αυτήν την εφαρμογή, δεν συμφωνείτε με τους κανονισμούς χρήσης Deezer', + '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': 'Επιλογή θέματος', + 'Dark': 'Σκούρο', + 'Black (AMOLED)': 'Μαύρο (AMOLED)', + 'Deezer (Dark)': 'Deezer (Σκούρο)', + 'Primary color': 'Πρωτεύον χρώμα', + 'Selected color': 'Επιλεγμένο χρώμα', + 'Use album art primary color': + 'Χρησιμοποιήστε το πρωτεύον χρώμα του εξώφυλλου του άλμπουμ', + 'Warning: might be buggy': 'Προειδοποίηση: μπορεί να μη λειτουργεί σωστά', + 'Mobile streaming': 'Ροή μέσω δεδομένων κινητού δικτύου', + 'Wifi streaming': 'Ροή μέσω WIFI', + '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.': + 'Αντιγραφή 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': 'Διαλέξτε ένα μονοπάτι', + 'Select storage': 'Επιλέξτε χώρο αποθήκευσης', + 'Go up': 'Πήγαινε πάνω', + 'Permission denied': 'Η άδεια απορρίφθηκε', + 'Language': 'Γλώσσα', + 'Language changed, please restart ReFreezer to apply!': + 'Η γλώσσα άλλαξε, κάντε επανεκκίνηση του ReFreezer για εφαρμογή!', + 'Importing...': 'Εισαγωγή...', + 'Radio': 'Ραδιόφωνο', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + 'Το κομμάτι δεν είναι διαθέσιμο στο Deezer!', + 'Failed to download track! Please restart.': + 'Αποτυχία λήψης κομματιού! Κάντε επανεκκίνηση. ', + 'Storage permission denied!': 'Η άδεια χώρου αποθήκευσης απορρίφθηκε!', + 'Failed': 'Απέτυχαν', + 'Queued': 'Σε ουρά', + 'External': 'Χώρος αποθήκευσης', + 'Restart failed downloads': 'Επανεκκίνηση αποτυχημένων λήψεων', + 'Clear failed': 'Εκκαθάριση αποτυχημένων', + 'Download Settings': 'Ρυθμίσεις Λήψεων', + 'Create folder for playlist': 'Δημιουργία φακέλου για λίστα αναπαραγωγής', + 'Download .LRC lyrics': 'Λήψη στίχων .LRC', + 'Proxy': 'Μεσολαβητής', + 'Not set': 'Δεν ρυθμίστηκε', + 'Search or paste URL': 'Αναζήτηση ή επικόλληση διεύθυνσης URL', + 'History': 'Ιστορικό', + 'Download threads': 'Ταυτόχρονες λήψεις', + 'Lyrics unavailable, empty or failed to load!': + 'Οι στίχοι δεν είναι διαθέσιμοι, είναι άδειοι ή δεν φορτώθηκαν!', + 'About': 'Σχετικά', + 'Telegram Channel': 'Κανάλι Telegram ', + 'To get latest releases': 'Για να λάβετε τις τελευταίες κυκλοφορίες', + 'Official chat': 'Επίσημη συνομιλία', + 'Telegram Group': 'Ομάδα Telegram', + 'Huge thanks to all the contributors! <3': + 'Πολλά ευχαριστώ σε όλους τους συνεισφέροντες! <3', + 'Edit playlist': 'Επεξεργασία λίστας αναπαραγωγής', + 'Update': 'Ενημέρωση', + 'Playlist updated!': 'Η λίστα αναπαραγωγής ενημερώθηκε!', + 'Downloads added!': 'Προστέθηκαν λήψεις!', + 'Save cover file for every track': 'Αποθήκευση εξώφυλλου για κάθε κομμάτι', + 'Download Log': 'Αρχείο καταγραφής λήψεων', + 'Repository': 'Αποθετήριο', + 'Source code, report issues there.': + 'Πηγαίος κώδικας, αναφέρετε ζητήματα εκεί.', + 'Use system theme': 'Χρησιμοποίηση θέματος συστήματος', + 'Light': 'Φωτεινο', + 'Popularity': 'Δημοτικότητα', + 'User': 'Χρήστης', + 'Track count': 'Αριθμός κομματιών', + "If you want to use custom directory naming - use '/' as directory separator.": + "Εάν θέλετε να χρησιμοποιήσετε προσαρμοσμένα ονόματα καταλόγου - χρησιμοποιήστε το '/' ως διαχωριστικό καταλόγου.", + 'Share': 'Κοινοποίηση', + 'Save album cover': 'Αποθήκευση εξώφυλλου άλμπουμ', + 'Warning': 'Προειδοποίηση', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Η χρήση πολλών ταυτόχρονων λήψεων σε παλαιότερες/ασθενέστερες συσκευές ενδέχεται να προκαλέσει σφάλματα!', + 'Create .nomedia files': 'Δημιουργία αρχείων .nomedia', + 'To prevent gallery being filled with album art': + 'Για την αποφυγή εμφάνισης εξώφυλλων άλμπουμ στις εικόνες', + 'Sleep timer': 'Χρονοδιακόπτης ύπνου', + 'Minutes:': 'Λεπτά:', + 'Hours:': 'Ώρες:', + 'Cancel current timer': 'Ακύρωση χρονοδιακόπτη', + 'Current timer ends at': 'Ο χρονοδιακόπτης λήγει σε', + 'Smart track list': 'Έξυπνη λίστα κομματιών', + 'Shuffle': 'Ανάμιξη', + 'Library shuffle': 'Ανάμιξη βιβλιοθήκης', + 'Ignore interruptions': 'Αγνόηση παρεμβάσεων', + 'Requires app restart to apply!': + 'Απαιτείται επανεκκίνηση για την εφαρμογή!', + 'Ask before downloading': 'Ερώτηση πριν από τη λήψη', + 'Search history': 'Ιστορικό αναζήτησης', + 'Clear search history': 'Εκκαθάριση ιστορικού αναζήτησης', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': + 'Συνδεθείτε για ενεργοποίηση του scrobbling.', + 'Login to LastFM': 'Σύνδεση σε LastFM', + 'Username': 'Όνομα χρήστη', + 'Password': 'Κωδικός', + 'Login': 'Σύνδεση', + 'Authorization error!': 'Σφάλμα εξουσιοδότησης!', + 'Logged out!': 'Αποσυνδεθήκατε!', + 'Lyrics': 'Στίχοι', + 'Player gradient background': 'Βαθμιαία κλίση χρώματος φόντου αναπαραγωγής', + 'Updates': 'Ενημερώσεις', + 'You are running latest version!': + 'Χρησιμοποιείτε την πιο πρόσφατη έκδοση!', + 'New update available!': 'Υπάρχει διαθέσιμη νέα ενημέρωση!', + 'Current version: ': 'Τρέχουσα έκδοση: ', + 'Unsupported platform!': 'Μη υποστηριζόμενη πλατφόρμα!', + 'Freezer Updates': 'Ενημερώσεις του Freezer', + 'Update to latest version in the settings.': + 'Ενημέρωση στην πιο πρόσφατη έκδοση από τις ρυθμίσεις.', + 'Release date': 'Ημερομηνία κυκλοφορίας', + 'Shows': 'Show', + 'Charts': 'Ακούγονται πολύ', + 'Browse': 'Περιήγηση', + 'Quick access': 'Γρήγορη Πρόσβαση', + 'Play mix': 'Αναπαραγωγή μίξης', + 'Share show': 'Κοινοποίηση show', + 'Date added': 'Ημερομηνία προσθήκης', + 'Discord': 'Discord', + 'Official Discord server': 'Επίσημος server Discord', + 'Restart of app is required to properly log out!': + 'Απαιτείται επανεκκίνηση της εφαρμογής για σωστή αποσύνδεση!', + 'Artist separator': 'Διαχωριστής καλλιτέχνη', + 'Singleton naming': 'Ονομασία αυτόνομων κομματιών', + 'Keep the screen on': 'Η οθόνη να παραμένει ενεργή', + 'Wakelock enabled!': 'Το ξύπνημα συσκευής ενεργοποιήθηκε!', + 'Wakelock disabled!': 'Το ξύπνημα συσκευής απενεργοποιήθηκε!', + 'Show all shows': 'Εμφάνιση όλων των show', + 'Episodes': 'Επεισόδια', + 'Show all episodes': 'Εμφάνιση όλων των επισοδίων', + 'Album cover resolution': 'Ανάλυση εξωφύλλων άλμπουμ', + "WARNING: Resolutions above 1200 aren't officially supported": + 'ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Δεν υποστηρίζονται επίσημα αναλύσεις άνω των 1200', + 'Album removed from library!': 'Το άλμπουμ καταργήθηκε από τη βιβλιοθήκη!', + 'Remove offline': 'Αφαίρεση εκτός σύνδεσης', + 'Playlist removed from library!': + 'Η λίστα αναπαραγωγής καταργήθηκε από τη βιβλιοθήκη!', + 'Blur player background': 'Σκούρο υποβάθρου αναπαραγωγέα', + 'Might have impact on performance': 'Μπορεί να έχει αντίκτυπο στην απόδοση', + 'Font': 'Γραμματοσειρά', + 'Select font': 'Επιλέξτε γραμματοσειρά', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Αυτή η εφαρμογή δεν έχει φτιαχτεί για να υποστηρίζει πολλές γραμματοσειρές, μπορεί να προκαλέσει λάθος διατάξεις και υπερχείλιση. Χρησιμοποιήστε το με δική σας ευθύνη!', + 'Enable equalizer': 'Ενεργοποίηση ισοσταθμιστή', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Μπορεί να επιτρέψει σε κάποιες εφαρμογές ισοσταθμιστή να λειτουργήσουν. Απαιτεί επανεκκίνηση του Freezer', + 'Visualizer': 'Οπτικοποιητής', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Εμφάνιση οπτικών εφέ στη σελίδα των στίχων. ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Απαιτείται άδεια μικροφώνου!', + 'Tags': 'Ετικέτες', + 'Album': 'Άλμπουμ', + 'Track number': 'Αριθμός τραγουδιού', + 'Disc number': 'Αριθμός δίσκου', + 'Album artist': 'Καλλιτέχνης Άλμπουμ', + 'Date/Year': 'Ημερομηνία/Έτος', + 'Label': 'Δισκογραφική εταιρία', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Συνολικός αριθμός κομματιών', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Μη συγχρονισμένοι στίχοι', + 'Genre': 'Είδος', + 'Contributors': 'Συντελεστές', + 'Album art': 'Εξώφυλλο άλμπουμ', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Το Deezer δεν είναι διαθέσιμο στη χώρα σας και μπορεί να μην λειτουργεί σωστά. Παρακαλούμε χρησιμοποιήστε ένα VPN', + 'Deezer is unavailable': 'Το Deezer δεν είναι διαθέσιμο', + 'Continue': 'Συνέχεια', + 'Email Login': 'Σύνδεση με Email', + 'Email': 'Email', + 'Missing email or password!': 'Λείπει το email ή ο κωδικός πρόσβασης!', + 'Error logging in using email, please check your credentials.\nError:': + 'Σφάλμα κατά τη σύνδεση μέσω email, παρακαλώ ελέγξτε τα διαπιστευτήριά σας.\nΣφάλμα:', + 'Error logging in!': 'Σφάλμα εισόδου!', + 'Change display mode': 'Εναλλαγή λειτουργίας προβολής', + 'Enable high refresh rates': 'Ενεργοποίηση υψηλών ρυθμών ανανέωσης', + 'Display mode': 'Λειτουργία προβολής', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Εισαγωγή λίστας αναπαραγωγής έως και 100 κομματιών χωρίς καμία σύνδεση.', + 'Download imported tracks': 'Λήψη εισαγόμενων κομματιών', + 'Start import': 'Έναρξη εισαγωγής', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Εισαγωγή οποιασδήποτε λίστας αναπαραγωγής Spotify, εισαγωγή από τη δική σας βιβλιοθήκη Spotify. Απαιτεί δωρεάν λογαριασμό.', + 'Spotify Importer v2': 'Εισαγωγέας Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Αυτός ο εισαγωγέας απαιτεί το αναγνωριστικό πελάτη Spotify και το μυστικό πελάτη. Για να τα αποκτήσετε:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Πηγαίνετε στο: developer.spotify.com/dashboard και δημιουργήστε μια εφαρμογή.', + 'Open in Browser': 'Άνοιγμα σε Πρόγραμμα περιήγησης', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. Στην εφαρμογή που μόλις δημιουργήσατε πηγαίνετε στις ρυθμίσεις, και ορίστε το URL Ανακατεύθυνσης σε: ', + 'Copy the Redirect URL': 'Αντιγραφή του URL Ανακατεύθυνσης', + 'Client ID': 'Αναγνωριστικό πελάτη', + 'Client Secret': 'Μυστικό Πελάτη', + 'Authorize': 'Εξουσιοδότηση', + 'Logged in as: ': 'Συνδεθήκατε ως: ', + 'Import playlists by URL': 'Εισαγωγή λιστών αναπαραγωγής από URL', + 'URL': 'URL', + 'Options': 'Επιλογές', + 'Invalid/Unsupported URL': 'Μη Έγκυρο/Μη Υποστηριζόμενο URL', + 'Please wait...': 'Παρακαλώ περιμένετε...', + 'Login using email': 'Σύνδεση μέσω ηλεκτρονικού ταχυδρομείου', + 'Track removed from offline!': 'Το κομμάτι αφαιρέθηκε από χωρίς σύνδεση!', + 'Removed album from offline!': 'Το άλμπουμ αφαιρέθηκε από χωρίς σύνδεση!', + 'Playlist removed from offline!': 'Η λίστα αφαιρέθηκε από χωρίς σύνδεση!', + 'Repeat': 'Επανάληψη', + 'Repeat one': 'Επανάληψη ενός', + 'Repeat off': 'Χωρίς επανάληψη', + 'Love': 'Το Αγαπώ', + 'Unlove': 'Δεν το Αγαπώ', + 'Dislike': 'Δεν μου αρέσει', + 'Close': 'Κλείσιμο', + 'Sort playlist': 'Ταξινόμηση λίστας αναπαραγωγής', + 'Sort ascending': 'Αύξουσα ταξινόμηση', + 'Sort descending': 'Φθίνουσα ταξινόμηση', + 'Stop': 'Διακοπή', + 'Start': 'Έναρξη', + 'Clear all': 'Απαλοιφή όλων', + 'Play previous': 'Αναπαραγωγή προηγούμενου', + 'Play': 'Αναπαραγωγή', + 'Pause': 'Παύση', + 'Remove': 'Αφαίρεση', + 'Seekbar': 'Γραμμή Αναζήτησης', + 'Singles': 'Σίνγκλ', + 'Featured': 'Προτεινόμενα', + 'Fans': 'Θαυμαστές', + 'Duration': 'Διάρκεια', + 'Sort': 'Ταξινόμηση', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Το ARL σας μπορεί να έχει λήξει, δοκιμάστε να αποσυνδεθείτε και να συνδεθείτε ξανά χρησιμοποιώντας νέο ARL ή το πρόγραμμα περιήγησης.' + }, + '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': + 'כרגע תומך רק ב-Spotify, עם הגבלה של 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': 'יבא רשימת השמעה מ-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': 'בחר ערכת נושא', + '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': '(ARL בלבד) המשך', + 'Log out & Exit': 'התנתק וצא', + 'Pick-a-Path': 'בחר נתיב', + 'Select storage': 'בחר אחסון', + 'Go up': 'עלה למעלה', + 'Permission denied': 'הרשאה נדחתה', + 'Language': 'שפה', + 'Language changed, please restart ReFreezer to apply!': + 'שפה שונתה, בבקשה הפעל מחדש את ReFreezer כדי להחיל!', + 'Importing...': 'מייבא...', + 'Radio': 'רדיו', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'שיר לא קיים בדיזר!', + 'Failed to download track! Please restart.': 'הורדת השיר נכשלה! התחל מחדש.', + 'Storage permission denied!': 'לא ניתנו הרשאות אחסון!', + 'Failed': 'נכשל', + 'Queued': 'הוכנס לתור', + 'External': 'אחסון', + 'Restart failed downloads': 'הפעל מחדש הורדות שכשלו', + 'Clear failed': 'מחק הורדות שכשלו', + 'Download Settings': 'הגדרות הורדה', + 'Create folder for playlist': 'צור תיקייה עבור רשימות השמעה', + 'Download .LRC lyrics': 'הורד קובץ מילים (LRC.)', + 'Proxy': 'פרוקסי', + 'Not set': 'לא הוגדר', + 'Search or paste URL': 'חפש או הזן קישור', + 'History': 'היסטוריה', + 'Download threads': 'הורדות בפעולה', + 'Lyrics unavailable, empty or failed to load!': + 'מילים לשיר אינן זמינות, ריקות או שכשלו להיטען!', + 'About': 'אודות', + 'Telegram Channel': 'ערוץ טלגרם', + 'To get latest releases': 'לקבלת הגרסאות החדשות ביותר', + 'Official chat': "צ'אט רשמי", + 'Telegram Group': 'קבוצת טלגרם', + 'Huge thanks to all the contributors! <3': 'תודות רבות לכל התורמים! 3>', + 'Edit playlist': 'עריכת רשימת ההשמעה', + 'Update': 'עדכון', + 'Playlist updated!': 'רשימת ההשמעה עודכנה!', + 'Downloads added!': 'הורדות נוספו!', + 'Save cover file for every track': 'שמור תמונת כיסוי לכל שיר', + 'Download Log': 'יומן הורדות', + 'Repository': 'מאגר', + 'Source code, report issues there.': 'קוד מקור, דיווח על בעיות שם.', + 'Use system theme': 'השתמש בערכת נושא של המערכת', + 'Light': 'בהיר', + 'Popularity': 'פופולריות', + 'User': 'משתמש', + 'Track count': 'מספר שיר', + "If you want to use custom directory naming - use '/' as directory separator.": + "אם ברצונך להשתמש בשמות ספרייה מותאמים אישית - השתמש ב-'/' כהפרדה בין התיקיות.", + 'Share': 'שיתוף', + 'Save album cover': 'שמור עטיפת אלבום', + 'Warning': 'אזהרה', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'יותר מדי הורדות במקביל עלולות לגרום לקריסות במכשירים ישנים או חלשים יותר!', + 'Create .nomedia files': 'יצירת קובץ .nomedia', + 'To prevent gallery being filled with album art': + 'כדי להמנע מהגלריה להתמלא בעטיפות אלבומים', + 'Sleep timer': 'טיימר שינה', + 'Minutes:': 'דקות:', + 'Hours:': 'שעות:', + 'Cancel current timer': 'בטל טיימר נוכחי', + 'Current timer ends at': 'טיימר נוכחי נגמר ב', + 'Smart track list': 'רשימת השמעה חכמה', + 'Shuffle': 'אקראי', + 'Library shuffle': 'השמע ספרייה באקראי', + 'Ignore interruptions': 'התעלם מהפרעות', + 'Requires app restart to apply!': + 'על מנת להחיל את השינויים יש להפעיל מחדש את האפליקציה!', + 'Ask before downloading': 'שאל לפני ההורדה', + 'Search history': 'היסטוריית חיפוש', + 'Clear search history': 'נקה את היסטוריית החיפוש', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'התחבר כדי לאפשר מיזוג.', + 'Login to LastFM': 'התחבר ל-LastFM', + 'Username': 'שם משתמש', + 'Password': 'סיסמה', + 'Login': 'התחבר', + 'Authorization error!': 'שגיאת אימות!', + 'Logged out!': 'התנתקת בהצלחה!', + 'Lyrics': 'מילות שיר', + 'Player gradient background': 'רקע מדורג בנגן המדיה', + 'Updates': 'עדכונים', + 'You are running latest version!': 'את/ה משתמש/ת בגירסה העדכנית ביותר!', + 'New update available!': 'גרסה חדשה זמינה!', + 'Current version: ': 'גירסה נוכחית: ', + 'Unsupported platform!': 'פלטפורמה זו אינה נתמכת!', + 'Freezer Updates': 'עדכוני Freezer', + 'Update to latest version in the settings.': + 'עדכנו לגרסה האחרונה דרך ההגדרות.', + 'Release date': 'תאריך שחרור', + 'Shows': 'Shows', + 'Charts': 'מצעדים', + 'Browse': 'עיון', + 'Quick access': 'גישה מהירה', + 'Play mix': 'הפעל מיקס', + 'Share show': 'שתף פודקאסט', + 'Date added': 'תאריך הוספה', + 'Discord': 'Discord', + 'Official Discord server': 'שרת ה-Discord הרשמי', + 'Restart of app is required to properly log out!': + 'על מנת לצאת יש להפעיל את האפליקציה מחדש!', + 'Artist separator': 'תו הפרדה בין אמנים', + 'Singleton naming': 'הפרדה לרצועות לפי שם', + 'Keep the screen on': 'השאר את המסך דולק', + 'Wakelock enabled!': 'Wakelock הופעל!', + 'Wakelock disabled!': 'Wakelock מושבת!', + 'Show all shows': 'הצג את כל הפודקאסטים', + 'Episodes': 'פרקים', + 'Show all episodes': 'הצג את כל הפרקים', + 'Album cover resolution': 'רזולוציה של עטיפת אלבום', + "WARNING: Resolutions above 1200 aren't officially supported": + 'אזהרה: רזולוציה מעל 1200 אינה נתמכת באופן רשמי', + 'Album removed from library!': 'האלבום הוסר מהספרייה!', + 'Remove offline': 'מחק קבצים לא מקוונים', + 'Playlist removed from library!': 'רשימת ההשמעה הוסרה מהספרייה!', + 'Blur player background': 'רקע נגן כהה', + 'Might have impact on performance': 'יכול להשפיע על הביצועים', + 'Font': 'גופן', + 'Select font': 'בחר גופן', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'האפליקציה לא בנויה לתמוך בגופנים רבים. השימוש באחריותך בלבד!', + 'Enable equalizer': 'הפעל אקולייזר', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'עשוי לאפשר לכמה אפליקציות אקולייזר לעבוד. מצריך הפעלה מחדש של Freezer', + 'Visualizer': 'הנפשה', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'הצגת הנפשות על מסך המילים. אזהרה: מצריך גישה למיקרופון!', + 'Tags': 'תגיות', + 'Album': 'אלבום', + 'Track number': 'מספר רצועה', + 'Disc number': 'מספר דיסק', + 'Album artist': 'אמן האלבום', + 'Date/Year': 'תאריך/שנה', + 'Label': 'תווית', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'סה\"כ רצועות', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'מילים לא מסונכרנות', + 'Genre': "ז'אנר", + 'Contributors': 'תורמים', + 'Album art': 'עטיפת אלבום', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer לא זמין במדינתך, לכן ReFreezer כנראה לא יפעל כראוי. אנא השתמש ב־VPN', + 'Deezer is unavailable': 'Deezer לא זמין', + 'Continue': 'המשך', + 'Email Login': 'כניסה באמצעות דוא\"ל', + 'Email': 'דוא\"ל', + 'Missing email or password!': 'הדוא\"ל או הסיסמה חסרים!', + 'Error logging in using email, please check your credentials.\nError:': + 'תקלה בכניסה עם הדוא\"ל שהוגש, אנא נסה שנית.\nשגיאה:', + 'Error logging in!': 'שגיאה בהתחברות!', + 'Change display mode': 'שנה את מצב התצוגה', + 'Enable high refresh rates': 'הפעל ריענון מסך בקצב גבוה', + 'Display mode': 'מצב תצוגה', + 'Spotify v1': 'גרסה 1 של Spotify', + 'Import Spotify playlists up to 100 tracks without any login.': + 'ייבוא עד 100 רשימות השמעה של Spotify מבלי להתחבר.', + 'Download imported tracks': 'הורדת רשימות ההשמעה שיובאו', + 'Start import': 'התחל יבוא', + 'Spotify v2': 'גרסה 2 של Spotify', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'ייבוא רשימות השמעה מהספרייה הפרטית שלכם ב־Spotify. יש צורך בחשבון חינמי.', + 'Spotify Importer v2': 'מייבא מ־Spotify גרסה 2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'המייבא זקוק לפרטי הלקוח ב־Sporify.\nכך משיגים אותם:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. היכנסו ל־developer.spotify.com/dashboard וצרו אפליקציה.', + 'Open in Browser': 'פתח בדפדפן', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. היכנסו להגדרות האפליקציה שיצרתם כרגע, ושנו את קישור ההפניה מחדש ל: ', + 'Copy the Redirect URL': 'העתיקו את קישור ההפניה מחדש', + 'Client ID': 'מזהה לקוח', + 'Client Secret': 'סוד הלקוח', + 'Authorize': 'הענק הרשאה', + 'Logged in as: ': 'מחובר כ: ', + 'Import playlists by URL': 'יבא רשימות השמעה באמצעות קישור', + 'URL': 'קישור', + 'Options': 'אפשרויות', + 'Invalid/Unsupported URL': 'קישור שגוי/לא נתמך', + 'Please wait...': 'אנא המתן...', + 'Login using email': 'התחברות באמצעות דוא\"ל', + 'Track removed from offline!': 'הרצועה הוסרה מהקבצים הלא מקוונים!', + 'Removed album from offline!': 'האלבום הוסר מהקבצים הלא מקוונים!', + 'Playlist removed from offline!': 'רשימת ההשמעה הוסרה מהקבצים הלא מקוונים!', + 'Repeat': 'חזרה', + 'Repeat one': 'חזור על נוכחי', + 'Repeat off': 'חזרה כבויה', + 'Love': 'אוהב', + 'Unlove': 'לא אוהב', + 'Dislike': 'לא אהבתי', + 'Close': 'סגור', + 'Sort playlist': 'מיין רשימת השמעה', + 'Sort ascending': 'מיין בסדר עולה', + 'Sort descending': 'מיין בסדר יורד', + 'Stop': 'עצור', + 'Start': 'התחל', + 'Clear all': 'נקה הכל', + 'Play previous': 'נגן את הקודם', + 'Play': 'נגן', + 'Pause': 'השהה', + 'Remove': 'הסר', + 'Seekbar': 'סרגל זמן', + 'Singles': 'שירים', + 'Featured': 'נבחרים', + 'Fans': 'אוהדים', + 'Duration': 'משך זמן', + 'Sort': 'מיון', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'hi_in': { + 'Home': 'होम', + 'Search': 'खोज', + 'Library': 'लाइब्रेरी', + "Offline mode, can't play flow or smart track lists.": + 'ऑफ़लाइन मोड, फ्लो या स्मार्ट ट्रैक सूची नहीं चला सकता', + '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': 'एपीआई सीमाओं के कारण', + '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.': + 'कृपया अपने डीज़र खाते का उपयोग करके लॉगिन करें।', + '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": + 'इस ऐप का उपयोग करके, आप 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': 'थीम चुने', + 'Dark': 'अंधेरा', + 'Black (AMOLED)': 'काला (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': + 'डीज़र को ट्रैक सुनने के लॉग भेजें, फ़्लो जैसी सुविधाओं के लिए इसे ठीक से काम करने के लिए सक्षम करें', + '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 ReFreezer to apply!': + 'भाषा बदली गई, कृपया आवेदन करने के लिए फ्रीजर को फिर से शुरू करें!', + 'Importing...': 'आयात किया जा रहा है...', + 'Radio': 'रेडियो', + 'Flow': 'बहे', + 'Track is not available on Deezer!': 'डीजर पर ट्रैक उपलब्ध नहीं है!', + 'Failed to download track! Please restart.': + 'ट्रैक डाउनलोड करने में विफल! कृपया पुनः प्रारंभ करें।', + 'Storage permission denied!': 'भंडारण की अनुमति अस्वीकृत!', + 'Failed': 'असफल', + 'Queued': 'कतारबद्ध', + 'External': 'भंडारण', + 'Restart failed downloads': 'असफल डाउनलोड पुनः प्रारंभ करें', + 'Clear failed': 'साफ़ विफल', + 'Download Settings': 'डाऊनलोड सैटिंग्स', + 'Create folder for playlist': 'प्लेलिस्ट के लिए फोल्डर बनाएं', + 'Download .LRC lyrics': 'डाउनलोड .LRC गीत', + 'Proxy': 'प्रॉक्सी', + 'Not set': 'सेट नहीं', + 'Search or paste URL': 'URL खोजें या पेस्ट करें', + 'History': 'इतिहास', + 'Download threads': 'समवर्ती डाउनलोड', + 'Lyrics unavailable, empty or failed to load!': + 'गीत अनुपलब्ध, खाली या लोड होने में विफल!', + 'About': 'बारे में', + 'Telegram Channel': 'टेलीग्राम चैनल', + 'To get latest releases': 'नवीनतम रिलीज़ प्राप्त करने के लिए', + 'Official chat': 'आधिकारिक चैट', + 'Telegram Group': 'टेलीग्राम समूह', + 'Huge thanks to all the contributors! <3': + 'सभी योगदानकर्ताओं का बहुत-बहुत धन्यवाद! <3', + 'Edit playlist': 'प्लेलिस्ट संपादित करें', + 'Update': 'अपडेट', + 'Playlist updated!': 'प्लेलिस्ट अपडेट की गई!', + 'Downloads added!': 'डाउनलोड जोड़े गए!', + 'Save cover file for every track': 'प्रत्येक ट्रैक के लिए कवर फ़ाइल सहेजें', + 'Download Log': 'लॉग डाउनलोड करें', + 'Repository': 'रिपॉजिटरी', + 'Source code, report issues there.': + 'स्रोत कोड, वहां मुद्दों की रिपोर्ट करें।', + 'Use system theme': 'सिस्टम थीम का उपयोग करें', + 'Light': 'हल्का', + 'Popularity': 'लोकप्रियता', + 'User': 'उपयोगकर्ता', + 'Track count': 'ट्रैक गिनती', + "If you want to use custom directory naming - use '/' as directory separator.": + "यदि आप कस्टम निर्देशिका नामकरण का उपयोग करना चाहते हैं - निर्देशिका विभाजक के रूप में '/' का उपयोग करें।", + 'Share': 'शेयर', + 'Save album cover': 'एल्बम कवर सहेजें', + 'Warning': 'चेतावनी', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'पुराने/कमजोर उपकरणों पर बहुत अधिक समवर्ती डाउनलोड का उपयोग करने से क्रैश हो सकता है!', + 'Create .nomedia files': '.nomedia फ़ाइलें बनाएं', + 'To prevent gallery being filled with album art': + 'गैलरी को एल्बम कला से भरने से रोकने के लिए', + 'Sleep timer': 'सोने का टाइमर', + 'Minutes:': 'मिनट:', + 'Hours:': 'घंटे:', + 'Cancel current timer': 'वर्तमान टाइमर रद्द करें', + 'Current timer ends at': 'वर्तमान टाइमर पर समाप्त होता है', + 'Smart track list': 'स्मार्ट ट्रैक सूची', + 'Shuffle': 'मिश्रण', + 'Library shuffle': 'पुस्तकालय फेरबदल', + 'Ignore interruptions': 'रुकावटों पर ध्यान न दें', + 'Requires app restart to apply!': + 'आवेदन करने के लिए ऐप रीस्टार्ट की आवश्यकता है!', + 'Ask before downloading': 'डाउनलोड करने से पहले पूछें', + 'Search history': 'खोज इतिहास', + 'Clear search history': 'खोज इतिहास साफ़ करें', + 'LastFM': 'आखरीएफएम', + 'Login to enable scrobbling.': 'स्क्रोब्लिंग सक्षम करने के लिए लॉगिन करें।', + 'Login to LastFM': 'लास्टएफएम में लॉग इन करें', + 'Username': 'उपयोगकर्ता नाम', + 'Password': 'पासवर्ड', + 'Login': 'लॉगिन', + 'Authorization error!': 'प्राधिकरण त्रुटि!', + 'Logged out!': 'बाहर आ गये!', + 'Lyrics': 'बोल', + 'Player gradient background': 'प्लेयर ग्रेडिएंट बैकग्राउंड', + 'Updates': 'अपडेट', + 'You are running latest version!': 'आप नवीनतम संस्करण चला रहे हैं!', + 'New update available!': 'नई सूचना उपलब्ध है!', + 'Current version: ': 'वर्तमान संस्करण: ', + 'Unsupported platform!': 'असमर्थित मंच!', + 'Freezer Updates': 'फ्रीजर अपडेट', + 'Update to latest version in the settings.': + 'सेटिंग्स में नवीनतम संस्करण में अपडेट करें।', + 'Release date': 'रिलीज़ की तारीख', + 'Shows': 'प्रदर्शन', + 'Charts': 'चार्ट', + 'Browse': 'ब्राउज़', + 'Quick access': 'त्वरित ऐक्सेस', + 'Play mix': 'प्ले मिक्स', + 'Share show': 'शेयर शो', + 'Date added': 'तारीख संकलित हुई', + 'Discord': 'डिस्कोर्ड', + 'Official Discord server': 'आधिकारिक डिस्कोड सर्वर', + 'Restart of app is required to properly log out!': + 'ठीक से लॉग आउट करने के लिए ऐप को पुनरारंभ करना आवश्यक है!', + 'Artist separator': 'कलाकार विभाजक', + 'Singleton naming': 'सिंगलटन नामकरण', + 'Keep the screen on': 'स्क्रीन चालू रखें', + 'Wakelock enabled!': 'वैकलॉक सक्षम!', + 'Wakelock disabled!': 'वैकलॉक अक्षम!', + 'Show all shows': 'सभी शो दिखाएं', + 'Episodes': 'एपिसोड', + 'Show all episodes': 'सभी एपिसोड दिखाएं', + 'Album cover resolution': 'एल्बम कवर रिज़ॉल्यूशन', + "WARNING: Resolutions above 1200 aren't officially supported": + 'चेतावनी: १२०० से ऊपर के रिज़ॉल्यूशन आधिकारिक तौर पर समर्थित नहीं हैं', + 'Album removed from library!': 'लाइब्रेरी से एल्बम निकाला गया!', + 'Remove offline': 'ऑफ़लाइन हटाएं', + 'Playlist removed from library!': 'लाइब्रेरी से प्लेलिस्ट हटाई गई!', + 'Blur player background': 'ब्लर प्लेयर बैकग्राउंड', + 'Might have impact on performance': 'प्रदर्शन पर असर पड़ सकता है', + 'Font': 'फ़ॉन्ट', + 'Select font': 'फ़ॉन्ट चुनें', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'यह ऐप कई फोंट का समर्थन करने के लिए नहीं बनाया गया है, यह लेआउट और अतिप्रवाह को तोड़ सकता है। अपने जोखिम पार इस्तेमाल करें!', + 'Enable equalizer': 'तुल्यकारक सक्षम करें', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'काम करने के लिए कुछ इक्वलाइज़र ऐप्स को सक्षम कर सकते हैं। फ्रीजर के पुनरारंभ की आवश्यकता है', + 'Visualizer': 'विजुआलाइज़र', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'गीत पृष्ठ पर विज़ुअलाइज़र दिखाएं। चेतावनी: माइक्रोफ़ोन अनुमति की आवश्यकता है!', + 'Tags': 'टैग्स', + 'Album': 'एल्बम', + 'Track number': 'ट्रैक संख्या', + 'Disc number': 'डिस्क संख्या', + 'Album artist': 'एल्बम कलाकार', + 'Date/Year': 'दिनांक/वर्ष', + 'Label': 'लेबल', + 'ISRC': 'आईएसआरसी', + 'UPC': 'यूपीसी', + 'Track total': 'कुल ट्रैक', + 'BPM': 'बीपीएम', + 'Unsynchronized lyrics': 'अतुल्यकालिक गीत', + 'Genre': 'शैली', + 'Contributors': 'योगदानकर्ताओं', + 'Album art': 'एलबम कला', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'आपके देश में डीज़र उपलब्ध नहीं है, हो सकता है कि फ्रीजर ठीक से काम न करे। कृपया एक वीपीएन का उपयोग करें', + 'Deezer is unavailable': 'डीज़र अनुपलब्ध है', + 'Continue': 'जारी रखें', + 'Email Login': 'ईमेल लॉगिन', + 'Email': 'ईमेल', + 'Missing email or password!': 'ईमेल या पासवर्ड गुम!', + 'Error logging in using email, please check your credentials.\nError:': + 'ईमेल का उपयोग करके लॉग इन करने में त्रुटि, कृपया अपने क्रेडेंशियल जांचें।\nत्रुटि:', + 'Error logging in!': 'लॉग इन करने में त्रुटि!', + 'Change display mode': 'प्रदर्शन मोड बदलें', + 'Enable high refresh rates': 'उच्च ताज़ा दर सक्षम करें', + 'Display mode': 'प्रदर्शन मोड', + 'Spotify v1': 'स्पॉटिफाई v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'बिना किसी लॉगिन के 100 ट्रैक तक Spotify प्लेलिस्ट आयात करें।', + 'Download imported tracks': 'आयातित ट्रैक डाउनलोड करें', + 'Start import': 'आयात शुरू करें', + 'Spotify v2': 'स्पॉटिफाई v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'किसी भी Spotify प्लेलिस्ट को आयात करें, अपनी Spotify लाइब्रेरी से आयात करें। मुफ्त खाते की आवश्यकता है।', + 'Spotify Importer v2': 'Spotify आयातक v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'इस आयातक को Spotify क्लाइंट आईडी और क्लाइंट सीक्रेट की आवश्यकता है। उन्हें प्राप्त करने के लिए:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. यहां जाएं: developer.spotify.com/dashboard और एक ऐप बनाएं।', + 'Open in Browser': 'ब्राउज़र में खोलें', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. आपके द्वारा अभी बनाए गए ऐप में सेटिंग्स पर जाएं, और रीडायरेक्ट यूआरएल को यहां सेट करें: ', + 'Copy the Redirect URL': 'रीडायरेक्ट URL कॉपी करें', + 'Client ID': 'ग्राहक ID', + 'Client Secret': 'क्लाइंट सीक्रेट', + 'Authorize': 'अधिकृत', + 'Logged in as: ': 'के रूप में लॉग इन किया: ', + 'Import playlists by URL': 'URL द्वारा प्लेलिस्ट आयात करें', + 'URL': 'यूआरएल', + 'Options': 'विकल्प', + 'Invalid/Unsupported URL': 'अमान्य/असमर्थित URL', + 'Please wait...': 'कृपया प्रतीक्षा करें...', + 'Login using email': 'ईमेल का उपयोग करके लॉगिन करें', + 'Track removed from offline!': 'ट्रैक को ऑफ़लाइन से हटा दिया गया!', + 'Removed album from offline!': 'एल्बम को ऑफ़लाइन से निकाला गया!', + 'Playlist removed from offline!': 'प्लेलिस्ट को ऑफ़लाइन से हटा दिया गया!', + 'Repeat': 'दोहराएँ', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'hu_hu': { + 'Home': 'Kezdőlap', + 'Search': 'Keresés', + 'Library': 'Kedvencek', + "Offline mode, can't play flow or smart track lists.": + 'Offline mód, nem lehet lejátszani a flow vagy az smart track listákat.', + 'Added to library': 'Hozzáadva a könyvtárhoz', + 'Download': 'Letöltés', + 'Disk': 'Lemez', + 'Offline': 'Offline', + 'Top Tracks': 'Legnépszerűbb dalok', + 'Show more tracks': 'Még több dal megjelenítése', + 'Top': 'Legnépszerűbb', + 'Top Albums': 'Legnépszerűbb albumok', + 'Show all albums': 'Összes album megjelenítése', + 'Discography': 'Diszkográfia', + 'Default': 'Alapértelmezett', + 'Reverse': 'Fordított', + 'Alphabetic': 'Ábécé sorrend', + 'Artist': 'Előadó', + 'Post processing...': 'Utófeldolgozás...', + 'Done': 'Kész', + 'Delete': 'Törlés', + 'Are you sure you want to delete this download?': + 'Biztos benne, hogy törölni szeretné ezt a letöltést?', + 'Cancel': 'Mégse', + 'Downloads': 'Letöltések', + 'Clear queue': 'Várólista ürítése', + "This won't delete currently downloading item": + 'Ezzel nem fog törlődni az aktuálisan letöltődő elem', + 'Are you sure you want to delete all queued downloads?': + 'Biztos, hogy törölni szeretné az összes várakozó letöltést?', + 'Clear downloads history': 'Letöltési előzmények törlése', + 'WARNING: This will only clear non-offline (external downloads)': + 'FIGYELMEZTETÉS: Ezzel csak a nem-offline letöltések kerülnek ürítésre (a külső letöltések)', + 'Please check your connection and try again later...': + 'Kérjük, ellenőrizze az internetkapcsolatát, majd próbálja meg újra...', + 'Show more': 'Több mutatása', + 'Importer': 'Importőr', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Jelenleg csak a Spotify támogatott, 100 dalig korlátozva', + 'Due to API limitations': 'API korlátozások miatt', + 'Enter your playlist link below': + 'Vigye be a lejátszási lista linkjét alul', + 'Error loading URL!': 'Hiba az URL betöltése közben!', + 'Convert': 'Átalakítás', + 'Download only': 'Kizárólag csak letöltés', + 'Downloading is currently stopped, click here to resume.': + 'A letöltés jelenleg szünetel, nyomjon ide a folytatáshoz.', + 'Tracks': 'Dalok', + 'Albums': 'Albumok', + 'Artists': 'Előadók', + 'Playlists': 'Lejátszási listák', + 'Import': 'Importálás', + 'Import playlists from Spotify': + 'Importálja a lejátszási listákat Spotify-ról', + 'Statistics': 'Statisztikák', + 'Offline tracks': 'Offline dalok', + 'Offline albums': 'Offline albumok', + 'Offline playlists': 'Offline lejátszási listák', + 'Offline size': 'Offline fájlméret', + 'Free space': 'Szabad tárhely', + 'Loved tracks': 'Kedvenc dalok', + 'Favorites': 'Kedvencek', + 'All offline tracks': 'Összes offline dal', + 'Create new playlist': 'Új lejátszási lista létrehozása', + 'Cannot create playlists in offline mode': + 'Offline módban nem lehet lejátszási listát létrehozni', + 'Error': 'Hiba', + 'Error logging in! Please check your token and internet connection and try again.': + 'Sikertelen bejelentkezés! Ellenőrizze az ARL tokent és az internetkapcsolatot, majd próbálja újra.', + 'Dismiss': 'Elvet', + 'Welcome to': 'Üdvözöljük', + 'Please login using your Deezer account.': + 'Kérjük, jelentkezzen be a Deezer fiókjával.', + 'Login using browser': 'Bejelentkezés böngészővel', + 'Login using token': 'Bejelentkezés ARL token-nel', + 'Enter ARL': 'ARL bevitele', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Mentés', + "If you don't have account, you can register on deezer.com for free.": + 'Ha nincs fiókja, regisztrálhat ingyen a deezer.com weboldalon.', + 'Open in browser': 'Megnyitás böngészőben', + "By using this app, you don't agree with the Deezer ToS": + 'Ennek az alkalmazásnak a használatával Ön nem egyezik bele a Deezer felhasználási feltételeibe', + 'Play next': 'Következő lejátszása', + 'Add to queue': 'Hozzáadás a várólistához', + 'Add track to favorites': 'Dal hozzáadása a kedvencekhez', + 'Add to playlist': 'Hozzáadás a lejátszási listához', + 'Select playlist': 'Lejátszási lista kiválasztása', + 'Track added to': 'Dal hozzáadva', + 'Remove from playlist': 'Eltávolítás a lejátszási listáról', + 'Track removed from': 'Dal eltávolítva', + 'Remove favorite': 'Kedvenc eltávolítása', + 'Track removed from library': 'Dal eltávolítva a könyvtárból', + 'Go to': 'Menjen', + 'Make offline': 'Tegye Offline-ba', + 'Add to library': 'Hozzáadás a könyvtárhoz', + 'Remove album': 'Album eltávolítása', + 'Album removed': 'Album eltávolítva', + 'Remove from favorites': 'Eltávolítás a kedvencek közül', + 'Artist removed from library': 'Előadó eltávolítva a könyvtárból', + 'Add to favorites': 'Hozzáadás a kedvencekhez', + 'Remove from library': 'Eltávolítás a könyvtárból', + 'Add playlist to library': 'Lejátszási lista hozzáadása a könyvtárhoz', + 'Added playlist to library': 'Lejátszási lista hozzáadva a könyvtárhoz', + 'Make playlist offline': 'Lejátszási lista offline módba tétele', + 'Download playlist': 'Lejátszási lista letöltése', + 'Create playlist': 'Lejátszási lista létrehozása', + 'Title': 'Cím', + 'Description': 'Leírás', + 'Private': 'Privát', + 'Collaborative': 'Együttműködés', + 'Create': 'Létrehozás', + 'Playlist created!': 'Lejátszási lista létrehozva!', + 'Playing from:': 'Lejátszás:', + 'Queue': 'Várólista', + 'Offline search': 'Offline keresés', + 'Search Results': 'Találatok', + 'No results!': 'Nincs találat!', + 'Show all tracks': 'Összes dal megjelenítése', + 'Show all playlists': 'Összes lejátszási lista megjelenítése', + 'Settings': 'Beállítások', + 'General': 'Általános', + 'Appearance': 'Megjelenés', + 'Quality': 'Minőség', + 'Deezer': 'Deezer', + 'Theme': 'Kinézet', + 'Currently': 'Jelenlegi', + 'Select theme': 'Kinézet választása', + 'Dark': 'Sötét', + 'Black (AMOLED)': 'Fekete (AMOLED)', + 'Deezer (Dark)': 'Deezer (Sötét)', + 'Primary color': 'Elsődleges szín', + 'Selected color': 'Kiválasztott szín', + 'Use album art primary color': 'Használja az albumborító elsődleges színét', + 'Warning: might be buggy': 'Figyelmeztetés: hibák előfordulhatnak', + 'Mobile streaming': 'Mobil streaming', + 'Wifi streaming': 'Wifi streaming', + 'External downloads': 'Külső letöltések', + 'Content language': 'Tartalom nyelve', + 'Not app language, used in headers. Now': + 'Nem az alkalmazás nyelve, fejléceknél használt. Jelenleg', + 'Select language': 'Válasszon nyelvet', + 'Content country': 'Tartalom országa', + 'Country used in headers. Now': + 'Ország a fejléceknél van használva. Jelenleg', + 'Log tracks': 'Dalok naplózása', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Elküldi a zenehallgatási naplókat a Deezer-nek, engedélyezze ezt, hogy megfelelően működjenek az olyan szolgáltatások, mint a Flow', + 'Offline mode': 'Offline mód', + 'Will be overwritten on start.': 'Induláskor felül lesz írva.', + 'Error logging in, check your internet connections.': + 'Hiba a bejelentkezéskor, ellenőrizd az internetkapcsolatot.', + 'Logging in...': 'Bejelentkezés...', + 'Download path': 'Letöltési útvonal', + 'Downloads naming': 'Letöltések címezése', + 'Downloaded tracks filename': 'Letöltött dalok fájlneve', + 'Valid variables are': 'Érvényes változók a következők', + 'Reset': 'Alaphelyzetbe állítás', + 'Clear': 'Kiürítés', + 'Create folders for artist': 'Mappák létrehozása előadókhoz', + 'Create folders for albums': 'Mappák létrehozása albumokhoz', + 'Separate albums by discs': 'Albumok elválasztása lemez számozás szerint', + 'Overwrite already downloaded files': 'Letöltött fájlok felülírása', + 'Copy ARL': 'ARL másolása', + 'Copy userToken/ARL Cookie for use in other apps.': + 'userToken/ARL kimásolása más alkalmazás használatához.', + 'Copied': 'Másolva', + 'Log out': 'Kijelentkezés', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Plugin inkompatibilitása miatt újraindítás nélkül nem lehetséges a böngészővel való bejelentkezés.', + '(ARL ONLY) Continue': '(Csak ARL) Folytatás', + 'Log out & Exit': 'Kijelentkezés és kilépés', + 'Pick-a-Path': 'Elérési útvonal kiválasztása', + 'Select storage': 'Tárhely kiválasztása', + 'Go up': 'Menjen fel', + 'Permission denied': 'Hozzáférés megtagadva', + 'Language': 'Nyelv', + 'Language changed, please restart ReFreezer to apply!': + 'A nyelv megváltozott, kérem indítsa újra a Freezer-t az alkalmazáshoz!', + 'Importing...': 'Importálás...', + 'Radio': 'Rádió', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Dal nem elérhető a Deezeren!', + 'Failed to download track! Please restart.': + 'Hiba a dal letöltése közben! Kérem indítsa újra.', + 'Storage permission denied!': 'Tárhely-hozzáférés megtagadva!', + 'Failed': 'Sikertelen', + 'Queued': 'Sorba állítva', + 'External': 'Tárhely', + 'Restart failed downloads': 'Sikertelen letöltések újraindítása', + 'Clear failed': 'Ürítés sikertelen', + 'Download Settings': 'Letöltés beállításai', + 'Create folder for playlist': 'Mappa létrehozása a lejátszási listához', + 'Download .LRC lyrics': '.LRC dalszöveg letöltése', + 'Proxy': 'Proxy', + 'Not set': 'Nincs beállítva', + 'Search or paste URL': 'Keressen, vagy illesszen be egy URL-t', + 'History': 'Előzmények', + 'Download threads': 'Egyidejű letöltések', + 'Lyrics unavailable, empty or failed to load!': + 'Dalszöveg nem elérhető. Nincs, vagy sikertelen volt a betöltés!', + 'About': 'Névjegy', + 'Telegram Channel': 'Telegram csatorna', + 'To get latest releases': 'Szerezze meg a legújabb kiadásokat', + 'Official chat': 'Hivatalos chat', + 'Telegram Group': 'Telegram csoport', + 'Huge thanks to all the contributors! <3': + 'Hatalmas köszönet minden egyes közreműködőnek! <3', + 'Edit playlist': 'Lejátszási lista szerkesztése', + 'Update': 'Frissítés', + 'Playlist updated!': 'Lejátszási lista frissítve!', + 'Downloads added!': 'Letöltések hozzáadva!', + 'Save cover file for every track': + 'Minden egyes dalnak mentse le az albumborító képét', + 'Download Log': 'Letöltési napló', + 'Repository': 'Gyűjtemény', + 'Source code, report issues there.': + 'Forráskód, jelezze vissza a hibákat itt.', + 'Use system theme': 'Rendszertéma használata', + 'Light': 'Világos', + 'Popularity': 'Népszerűség', + 'User': 'Felhasználó', + 'Track count': 'Sávszám', + "If you want to use custom directory naming - use '/' as directory separator.": + "Ha szeretnél egyedi könyvtár elnevezést, használd a '/' jelet, mint könyvtár elválasztót.", + 'Share': 'Megosztás', + 'Save album cover': 'Albumborító elmentése', + 'Warning': 'Figyelmeztetés', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'A túl sok egyidejű letöltés gyengébb eszközökön összeomlást eredményezhet!', + 'Create .nomedia files': '.nomedia fájlok létrehozása', + 'To prevent gallery being filled with album art': + ', hogy megelőzze a galériába kerülő esetlegesen túl sok albumborítót', + 'Sleep timer': 'Elalvás időzítő', + 'Minutes:': 'Percek:', + 'Hours:': 'Órák:', + 'Cancel current timer': 'A jelenlegi időzítés törlése', + 'Current timer ends at': 'Jelenlegi időzítő véget ér', + 'Smart track list': 'Smart track lista', + 'Shuffle': 'Keverés', + 'Library shuffle': 'Könyvtár keverés', + 'Ignore interruptions': 'Megszakítás figyelmen kívül hagyása', + 'Requires app restart to apply!': + 'Szükséges az alkalmazás újraindítása a beállítások érvénybe lépéséhez!', + 'Ask before downloading': 'Kérdezzen rá a letöltés előtt', + 'Search history': 'Keresési előzmények', + 'Clear search history': 'Keresési előzmények törlése', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': + 'Jelentkezzen be a scrobbling engedélyezéséhez. (megjegyződnek a lejátszott számok, így releváns dalokat ajánl majd a rendszer)', + 'Login to LastFM': 'Jelentkezzen be a LastFM-be', + 'Username': 'Felhasználónév', + 'Password': 'Jelszó', + 'Login': 'Bejelentkezés', + 'Authorization error!': 'Hitelesítési hiba!', + 'Logged out!': 'Kijelentkezve!', + 'Lyrics': 'Lyrics', + 'Player gradient background': 'Lejátszó háttér színátmenet', + 'Updates': 'Frissítések', + 'You are running latest version!': 'Legújabb verzió van telepítve!', + 'New update available!': 'Új frissítés elérhető!', + 'Current version: ': 'Jelenlegi verzió: ', + 'Unsupported platform!': 'Nem támogatott eszköz!', + 'Freezer Updates': 'Freezer frissítések', + 'Update to latest version in the settings.': + 'Frissítés a legújabb verzióra a beállításokban.', + 'Release date': 'Kiadás dátuma', + 'Shows': 'Műsorok', + 'Charts': 'Charts', + 'Browse': 'Böngészés', + 'Quick access': 'Gyors elérés', + 'Play mix': 'Mix lejátszása', + 'Share show': 'Műsor megosztása', + 'Date added': 'Hozzáadva', + 'Discord': 'Discord', + 'Official Discord server': 'Hivatalos Discord szerver', + 'Restart of app is required to properly log out!': + 'Alkalmazást újra kell indítani, hogy a kilépés megtörténjen!', + 'Artist separator': 'Előadó elválasztó', + 'Singleton naming': 'Különálló dalonkénti névformátum', + 'Keep the screen on': 'Kijelző bekapcsolva tartása', + 'Wakelock enabled!': 'Wakelock engedélyezve!', + 'Wakelock disabled!': 'Wakelock letiltva!', + 'Show all shows': 'Összes műsor megjelenítése', + 'Episodes': 'Epizódok', + 'Show all episodes': 'Minden epizód megjelenítése', + 'Album cover resolution': 'Album kép felbontása', + "WARNING: Resolutions above 1200 aren't officially supported": + 'Figyelem: Bármilyen felbontás 1200 felett nem támogatott', + 'Album removed from library!': 'Album eltávolítva a könyvtárból!', + 'Remove offline': 'Offline eltávolítása', + 'Playlist removed from library!': + 'Lejátszó lista eltávolítva a könyvtárból!', + 'Blur player background': 'Lejátszó háttér elmosása', + 'Might have impact on performance': 'Teljesítmény csökkenést okozhat', + 'Font': 'Betűtípus', + 'Select font': 'Betűtípus választása', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Ez az app nem támogat sok betűtípust, így felboríthatja a kinézetet. Csak kizárólag saját felelősségre!', + 'Enable equalizer': 'Equalézer engedélyezése', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Külső equalézer appok támogatása. Újraindítás szükséges', + 'Visualizer': 'Vizualizáló', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Vizualizáló mutatása a dalszöveg oldalon. VIGYÁZAT: mikrofon engedély szükséges!', + 'Tags': 'Címkék', + 'Album': 'Album', + 'Track number': 'Dal sorszáma', + 'Disc number': 'Lemez sorszám', + 'Album artist': 'Album előadó', + 'Date/Year': 'Év', + 'Label': 'Címke', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Összes zeneszám', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Szinkronizálatlan dalszövegek', + 'Genre': 'Műfaj', + 'Contributors': 'Közreműködők', + 'Album art': 'Borítókép', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'A Deezer nem elérhető az Ön országában, ezért a ReFreezer nem biztos hogy működni fog. Kérjük használjon VPN-t.', + 'Deezer is unavailable': 'A Deezer nem elérhető', + 'Continue': 'Tovább', + 'Email Login': 'Emailes belépés', + 'Email': 'E-mail', + 'Missing email or password!': 'Hiányzó email vagy jelszó!', + 'Error logging in using email, please check your credentials.\nError:': + 'Hiba a belépéskor, kérem ellenőrizze a belépési adatait.\nHiba:', + 'Error logging in!': 'Hiba a belépés során!', + 'Change display mode': 'Megjelenítési mód megváltoztatása', + 'Enable high refresh rates': 'Magas frissítési gyakoriság bekapcsolása', + 'Display mode': 'Megjelenítési mód', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Belépés nélküli Spotify lejátszási lista importálás akár 100 számig.', + 'Download imported tracks': 'Importált számok letöltése', + 'Start import': 'Importálás kezdése', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Bármelyik Spotify lejátszási lista importálása, saját kedvencek közül is. Legalább egy ingyenes fiók szükséges.', + 'Spotify Importer v2': 'Spotify Importáló v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Spotify Client ID vagy Client Secret szükséges ehhez az importálóhoz. A következőképpen lekérdezhető:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Nyisd meg a developer.spotify.com/dashboard linket és készíts egy appot.', + 'Open in Browser': 'Megnyitás böngészőben', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. A készített appban lépj a beállításokba, és állítsd be a Redirect URL-t a következőre: ', + 'Copy the Redirect URL': 'A Redirect URL másolása', + 'Client ID': 'Kliens ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Engedélyezés', + 'Logged in as: ': 'Következőként bejelentkezve: ', + 'Import playlists by URL': 'Lejátszási lista link alapján importálása', + 'URL': 'Webcím', + 'Options': 'Opciók', + 'Invalid/Unsupported URL': 'Helytelen / nem támogatott link', + 'Please wait...': 'Kérjük, várjon...', + 'Login using email': 'Belépés email-lel', + 'Track removed from offline!': + 'Szám nem elérhető kapcsolat nélküli üzemmódban!', + 'Removed album from offline!': + 'Album nem elérhető kapcsolat nélküli üzemmódban!', + 'Playlist removed from offline!': + 'Lejátszási lista eltávolítva a kapcsolat nélküli üzemmódból!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'id_id': { + 'Home': 'Beranda', + 'Search': 'Cari', + 'Library': 'Koleksi', + "Offline mode, can't play flow or smart track lists.": + 'Mode offline, tidak dapat memutar aliran atau daftar putar pintar.', + 'Added to library': 'Ditambahkan ke koleksi', + 'Download': 'Unduh', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Lagu Populer', + 'Show more tracks': 'Tampilkan lebih banyak lagu', + 'Top': 'Populer ', + 'Top Albums': 'Album Populer', + 'Show all albums': 'Tampilkan semua album', + 'Discography': 'Diskografi', + 'Default': 'Standar', + 'Reverse': 'Membalik', + 'Alphabetic': 'Alfabet', + 'Artist': 'Artis', + 'Post processing...': 'Sedang diproses...', + 'Done': 'Selesai', + 'Delete': 'Hapus', + 'Are you sure you want to delete this download?': + 'Apakah kamu yakin ingin menghapus unduhan ini?', + 'Cancel': 'Batalkan', + 'Downloads': 'Unduhan', + 'Clear queue': 'Bersihkan daftar antrean', + "This won't delete currently downloading item": + 'Ini tidak akan menghapus item yang sedang diunduh', + 'Are you sure you want to delete all queued downloads?': + 'Apakah kamu yakin ingin menghapus semua antrean yang terunduh?', + 'Clear downloads history': 'Bersihkan riwayat unduhan', + 'WARNING: This will only clear non-offline (external downloads)': + 'PERINGATAN: Ini hanya akan menghapus non-offline (unduhan eksternal)', + 'Please check your connection and try again later...': + 'Periksa kembali koneksi internet anda dan ulangi kembali...', + 'Show more': 'Tampilkan lebih banyak', + 'Importer': 'Telah di impor', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Saat ini hanya mendukung Spotify, dengan batas 100 lagu', + 'Due to API limitations': 'Karena keterbatasan API', + 'Enter your playlist link below': + 'Masukkan link playlist Anda di bawah ini', + 'Error loading URL!': 'Gagal memuat URL!', + 'Convert': 'Konversikan', + 'Download only': 'Hanya mengunduh', + 'Downloading is currently stopped, click here to resume.': + 'Pengunduhan saat ini dihentikan, klik di sini untuk melanjutkan.', + 'Tracks': 'Lagu', + 'Albums': 'Album', + 'Artists': 'Artis', + 'Playlists': 'Daftar Putar', + 'Import': 'Impor', + 'Import playlists from Spotify': 'Impor playlist dari Spotify', + 'Statistics': 'Statistik', + 'Offline tracks': 'Lagu offline', + 'Offline albums': 'Album offline', + 'Offline playlists': 'Daftar putar offline', + 'Offline size': 'Ukuran offline', + 'Free space': 'Penyimpanan tersedia', + 'Loved tracks': 'Lagu yang disukai', + 'Favorites': 'Favorit', + 'All offline tracks': 'Semua lagu offline', + 'Create new playlist': 'Buat daftar putar baru', + 'Cannot create playlists in offline mode': + 'Tidak dapat membuat daftar putar di mode offline', + 'Error': 'Terjadi kesalahan', + 'Error logging in! Please check your token and internet connection and try again.': + 'Kesalahan saat masuk! Periksa token dan koneksi internet Anda, lalu coba lagi.', + 'Dismiss': 'Tutup', + 'Welcome to': 'Selamat datang di', + 'Please login using your Deezer account.': + 'Silakan masuk menggunakan akun Deezer Anda.', + 'Login using browser': 'Masuk menggunakan browser', + 'Login using token': 'Masuk menggunakan token', + 'Enter ARL': 'Masukkan ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Simpan', + "If you don't have account, you can register on deezer.com for free.": + 'Jika Anda tidak memiliki akun, Anda dapat mendaftar di deezer.com secara gratis.', + 'Open in browser': 'Buka di browser', + "By using this app, you don't agree with the Deezer ToS": + 'Dengan menggunakan aplikasi ini, Anda tidak setuju dengan ToS Deezer', + 'Play next': 'Putar selanjutnya', + 'Add to queue': 'Tambahkan ke antrean', + 'Add track to favorites': 'Tambahkan lagu ke favorit', + 'Add to playlist': 'Tambahkan ke daftar putar', + 'Select playlist': 'Pilih daftar putar', + 'Track added to': 'Lagu ditambahkan ke', + 'Remove from playlist': 'Hapus dari daftar putar', + 'Track removed from': 'Lagu dihapus dari', + 'Remove favorite': 'Hapus favorit', + 'Track removed from library': 'Lagu dihapus dari koleksi', + 'Go to': 'Pergi ke', + 'Make offline': 'Buat offline', + 'Add to library': 'Tambahkan ke koleksi', + 'Remove album': 'Hapus album', + 'Album removed': 'Album dihapus', + 'Remove from favorites': 'Hapus dari favorit', + 'Artist removed from library': 'Artis dihapus dari koleksi', + 'Add to favorites': 'Tambahkan ke favorit', + 'Remove from library': 'Hapus dari koleksi', + 'Add playlist to library': 'Tambahkan daftar putar ke koleksi', + 'Added playlist to library': 'Ditambahkan daftar putar ke koleksi', + 'Make playlist offline': 'Buat daftar putar offline', + 'Download playlist': 'Unduh daftar putar', + 'Create playlist': 'Buat daftar putar', + 'Title': 'Judul', + 'Description': 'Deskripsi', + 'Private': 'Pribadi', + 'Collaborative': 'Kolaboratif', + 'Create': 'Buat', + 'Playlist created!': 'Daftar putar berhasil dibuat!', + 'Playing from:': 'Memainkan dari:', + 'Queue': 'Antrean', + 'Offline search': 'Pencarian offline', + 'Search Results': 'Hasil Pencarian', + 'No results!': 'Hasil tidak ditemukan!', + 'Show all tracks': 'Tampilkan semua lagu', + 'Show all playlists': 'Tampilkan semua daftar putar', + 'Settings': 'Pengaturan', + 'General': 'Umum', + 'Appearance': 'Tampilan', + 'Quality': 'Kualitas', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Saat ini', + 'Select theme': 'Pilih tema', + 'Dark': 'Gelap', + 'Black (AMOLED)': 'Hitam (AMOLED)', + 'Deezer (Dark)': 'Deezer (Gelap)', + 'Primary color': 'Warna utama', + 'Selected color': 'Warna yang dipilih', + 'Use album art primary color': 'Gunakan foto album sebagai warna utama', + 'Warning: might be buggy': 'Peringatan: masih ada bug', + 'Mobile streaming': 'Pemutaran seluler', + 'Wifi streaming': 'Pemutaran Wi-Fi', + 'External downloads': 'Unduhan eksternal', + 'Content language': 'Bahasa konten', + 'Not app language, used in headers. Now': + 'Bukan bahasa aplikasi, digunakan di header. Digunakan', + 'Select language': 'Pilih bahasa', + 'Content country': 'Wilayah konten', + 'Country used in headers. Now': 'Negara digunakan di header. Digunakan', + 'Log tracks': 'Catatan lagu', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Kirim catatan mendengarkan lagu ke Deezer, aktifkan agar fitur seperti Flow berfungsi dengan benar', + 'Offline mode': 'Mode offline', + 'Will be overwritten on start.': + 'Akan diterapkan saat aplikasi dimulai ulang.', + 'Error logging in, check your internet connections.': + 'Kesalahan saat masuk, periksa koneksi internet Anda.', + 'Logging in...': 'Masuk...', + 'Download path': 'Lokasi unduhan', + 'Downloads naming': 'Penamaan unduhan', + 'Downloaded tracks filename': 'Nama file yang diunduh', + 'Valid variables are': 'Variabel yang valid', + 'Reset': 'Atur ulang', + 'Clear': 'Bersihkan', + 'Create folders for artist': 'Buat folder untuk artis', + 'Create folders for albums': 'Buat folder untuk album', + 'Separate albums by discs': 'Pisahkan album dengan disk', + 'Overwrite already downloaded files': 'Timpa file yang sudah diunduh', + 'Copy ARL': 'Salin ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Salin Token/ARL Cookie untuk digunakan di apps lain.', + 'Copied': 'Tersalin', + 'Log out': 'Keluar', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Karena ketidakcocokan plugin, masuk menggunakan browser tidak tersedia tanpa restart aplikasi.', + '(ARL ONLY) Continue': '(HANYA ARL) Lanjutkan', + 'Log out & Exit': 'Keluar & Tutup', + 'Pick-a-Path': 'Pilih-sebuah-Jalur', + 'Select storage': 'Pilih penyimpanan', + 'Go up': 'Ke Atas', + 'Permission denied': 'Akses dilarang', + 'Language': 'Bahasa', + 'Language changed, please restart ReFreezer to apply!': + 'Bahasa diganti, Mulai ulang aplikasi untuk menerapkannya!', + 'Importing...': 'Mengimpor...', + 'Radio': 'Radio', + 'Flow': 'Alur', + 'Track is not available on Deezer!': 'Lagu tidak tersedia di Deezer!', + 'Failed to download track! Please restart.': + 'Gagal untuk mengunduh lagu! Ulangi kembali.', + 'Storage permission denied!': 'Izin penyimpanan ditolak!', + 'Failed': 'Gagal', + 'Queued': 'Dalam antrean', + 'External': 'Penyimpanan', + 'Restart failed downloads': 'Gagal memulai ulang unduhan', + 'Clear failed': 'Gagal membersihkan', + 'Download Settings': 'Pengaturan unduhan', + 'Create folder for playlist': 'Buat folder dari daftar putar', + 'Download .LRC lyrics': 'Unduh lirik .LRC', + 'Proxy': 'Proksi', + 'Not set': 'Tidak diatur', + 'Search or paste URL': 'Cari atau masukkan URL', + 'History': 'Riwayat', + 'Download threads': 'Unduh bersamaan', + 'Lyrics unavailable, empty or failed to load!': + 'Lirik tidak tersedia, kosong atau gagal untuk memuat!', + 'About': 'Tentang', + 'Telegram Channel': 'Channel Telegram', + 'To get latest releases': 'Untuk mendapatkan rilisan terbaru', + 'Official chat': 'Obrolan resmi', + 'Telegram Group': 'Grub Telegram', + 'Huge thanks to all the contributors! <3': + 'Terima kasih banyak untuk semua kontributor! <3', + 'Edit playlist': 'Edit daftar putar', + 'Update': 'Perbarui', + 'Playlist updated!': 'Daftar putar diperbarui!', + 'Downloads added!': 'Unduhan ditambahkan!', + 'Save cover file for every track': 'Simpan cover foto dari setiap lagu', + 'Download Log': 'Catatan unduhan', + 'Repository': 'Repositori', + 'Source code, report issues there.': + 'Kode sumber, laporkan masalah disini.', + 'Use system theme': 'Gunakan tema sistem', + 'Light': 'Cerah', + 'Popularity': 'Popularitas', + 'User': 'Pengguna', + 'Track count': 'Jumlah lagu', + "If you want to use custom directory naming - use '/' as directory separator.": + "Jika anda ingin menggunakan penamaan direktori kustom - gunakan '/' sebagai pemisah direktori.", + 'Share': 'Bagikan', + 'Save album cover': 'Simpan cover foto album', + 'Warning': 'Peringatan', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Menggunakan terlalu banyak unduhan bersamaan pada perangkat lama/berspesifikasi rendah dapat menyebabkan kerusakan file!', + 'Create .nomedia files': 'Buat file .nomedia', + 'To prevent gallery being filled with album art': + 'Untuk mencegah galeri diisi dengan cover album', + 'Sleep timer': 'Pengatur waktu tidur', + 'Minutes:': 'Menit:', + 'Hours:': 'Jam:', + 'Cancel current timer': 'Batalkan pengatur waktu saat ini', + 'Current timer ends at': 'Pengatur waktu saat ini berakhir pada', + 'Smart track list': 'Daftar lagu pintar', + 'Shuffle': 'Putar acak', + 'Library shuffle': 'Koleksi acak', + 'Ignore interruptions': 'Abaikan gangguan', + 'Requires app restart to apply!': 'Mulai ulang aplikasi untuk menerapkan!', + 'Ask before downloading': 'Tanyakan sebelum mengunduh', + 'Search history': 'Riwayat pencarian', + 'Clear search history': 'Bersihkan riwayat pencarian', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Masuk untuk mengaktifkan scrobbling.', + 'Login to LastFM': 'Masuk ke LastFM', + 'Username': 'Nama Pengguna', + 'Password': 'Kata Sandi', + 'Login': 'Masuk', + 'Authorization error!': 'Otentikasi gagal!', + 'Logged out!': 'Keluar!', + 'Lyrics': 'Lirik', + 'Player gradient background': 'Latar belakang pemutaran gradient', + 'Updates': 'Pembaruan', + 'You are running latest version!': 'Anda menjalankan versi terbaru!', + 'New update available!': 'Pembaruan tersedia!', + 'Current version: ': 'Versi saat ini: ', + 'Unsupported platform!': 'Platform Tidak Didukung!', + 'Freezer Updates': 'Pembaruan Freezer', + 'Update to latest version in the settings.': + 'Perbarui ke versi terakhir di pengaturan.', + 'Release date': 'Tanggal rilis', + 'Shows': 'Tampilkan', + 'Charts': 'Tangga lagu', + 'Browse': 'Telusuri', + 'Quick access': 'Akses cepat', + 'Play mix': 'Putar acak', + 'Share show': 'Bagikan acara', + 'Date added': 'Tanggal ditambahkan', + 'Discord': 'Discord', + 'Official Discord server': 'Server Resmi Discord', + 'Restart of app is required to properly log out!': + 'Perlu memulai ulang aplikasi untuk keluar secara benar!', + 'Artist separator': 'Pemisah artis', + 'Singleton naming': 'Penamaan tunggal', + 'Keep the screen on': 'Biarkan layar tetap menyala', + 'Wakelock enabled!': 'Wakelock diaktifkan!', + 'Wakelock disabled!': 'Wakelock dinonaktifkan!', + 'Show all shows': 'Tampilkan semua pertunjukan', + 'Episodes': 'Episode', + 'Show all episodes': 'Tampilkan semua episode', + 'Album cover resolution': 'Resolusi cover album', + "WARNING: Resolutions above 1200 aren't officially supported": + 'PERINGATAN: Resolusi diatas 1200 tidak sepenuhnya didukung', + 'Album removed from library!': 'Album dihapus dari koleksi!', + 'Remove offline': 'Hapus berkas offline', + 'Playlist removed from library!': 'Daftar Putar dihapus dari koleksi!', + 'Blur player background': 'Latar belakang pemutar buram', + 'Might have impact on performance': + 'Mungkin berdampak pada performa perangkat anda', + 'Font': 'Font', + 'Select font': 'Pilih font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Aplikasi ini tidak dibuat untuk mendukung banyak font, ini dapat merusak tata letak dan tampilan. Gunakan dengan resiko anda sendiri!', + 'Enable equalizer': 'Aktifkan equalizer', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Mungkin mengaktifkan beberapa aplikasi equalizer untuk bekerja. Membutuhkan restart Freezer', + 'Visualizer': 'Visualisasi', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Tampilkan visualisasi di halaman lirik. PERINGATAN: Dibutuhkan ijin microphone!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Nomor lagu', + 'Disc number': 'Nomor disk', + 'Album artist': 'Album Artis', + 'Date/Year': 'Tanggal/Tahun', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Total lagu', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Lirik yang tidak tersinkronisasi', + 'Genre': 'Genre', + 'Contributors': 'Kontributor', + 'Album art': 'Gambar album', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer tidak tersedia di negara Anda, ReFreezer mungkin tidak berfungsi dengan baik. Mohon gunakan VPN', + 'Deezer is unavailable': 'Deezer tidak tersedia', + 'Continue': 'Lanjutkan', + 'Email Login': 'Email Masuk', + 'Email': 'Email', + 'Missing email or password!': 'Email atau kata sandi salah!', + 'Error logging in using email, please check your credentials.\nError:': + 'Kesalahan saat masuk menggunakan email. Silahkan cek datanya lagi.\nKesalahan:', + 'Error logging in!': 'Gagal masuk!', + 'Change display mode': 'Ganti mode tampilan', + 'Enable high refresh rates': 'Aktifkan refresh rate tinggi', + 'Display mode': 'Mode tampilan', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Impor daftar putar Spotify sampai 100 lagu tanpa masuk.', + 'Download imported tracks': 'Mengunduh lagu yang diimpor', + 'Start import': 'Mulai impor', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Impor semua daftat putar Spotify, Impor dari koleksi Spotifymu. Dibutuhkan akun gratis.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Importer membutuhkan Spotify Client ID dan Client Secret. \nUntuk mendapatkannya:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Pergi ke: developer.spotify.com/dashboard dan buat sebuah app.', + 'Open in Browser': 'Buka di Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. Di app yang baru kamu buat pergi ke pengaturan, dan atur Redirect URL ke: ', + 'Copy the Redirect URL': 'Salin Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Masuk sebagai: ', + 'Import playlists by URL': 'Impor daftar putar dengan URL', + 'URL': 'URL', + 'Options': 'Opsi', + 'Invalid/Unsupported URL': 'URL Tidak Valid/Tidak Didukung', + 'Please wait...': 'Tunggu sebentar...', + 'Login using email': 'Masuk menggunakan email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'it_id': { + 'Home': 'Home', + 'Search': 'Cerca', + 'Library': 'Libreria', + "Offline mode, can't play flow or smart track lists.": + 'Modalità offline, non è possibile riprodurre Flow o Playlist intelligenti.', + 'Added to library': 'Aggiunto alla libreria', + 'Download': 'Scarica', + 'Disk': 'Disco', + 'Offline': 'Offline', + 'Top Tracks': 'Brani in evidenza', + 'Show more tracks': 'Mostra altri brani', + 'Top': 'In alto', + 'Top Albums': 'Album in evidenza', + 'Show all albums': 'Mostra tutti gli album', + 'Discography': 'Discografia', + 'Default': 'Predefinito', + 'Reverse': 'Inverti', + 'Alphabetic': 'Alfabetico', + 'Artist': 'Artista', + 'Post processing...': 'Elaborazione...', + 'Done': 'Completato', + 'Delete': 'Cancellare', + 'Are you sure you want to delete this download?': + 'Sei sicuro di voler cancellare questo download?', + 'Cancel': 'Annulla', + 'Downloads': 'Download', + 'Clear queue': 'Pulisci la coda', + "This won't delete currently downloading item": + 'Questa azione non cancellerà i download correnti', + 'Are you sure you want to delete all queued downloads?': + 'Sei sicuro di voler cancellare tutti i download in coda?', + 'Clear downloads history': 'Pulisci la cronologia dei download', + 'WARNING: This will only clear non-offline (external downloads)': + 'ATTENZIONE: Questa azione eliminerà solo i file che non sono offline (download esterni)', + 'Please check your connection and try again later...': + 'Per favore controlla la tua connessione e riprova più tardi...', + 'Show more': 'Mostra di più', + 'Importer': 'Importa', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Attualmente supporta solo Spotify, con un limite di 100 brani', + 'Due to API limitations': 'A causa delle limitazioni delle API', + 'Enter your playlist link below': + 'Inserisci il link della tua playlist qui sotto', + 'Error loading URL!': 'Errore caricamento URL!', + 'Convert': 'Converti', + 'Download only': 'Solo Download', + 'Downloading is currently stopped, click here to resume.': + 'Il download è attualmente interrotto, fare clic qui per riprenderlo.', + 'Tracks': 'Brani', + 'Albums': 'Album', + 'Artists': 'Artisti', + 'Playlists': 'Playlist', + 'Import': 'Importa', + 'Import playlists from Spotify': 'Importa playlists da Spotify', + 'Statistics': 'Statistiche', + 'Offline tracks': 'Brani offline', + 'Offline albums': 'Album offline', + 'Offline playlists': 'Playlist offline', + 'Offline size': 'Spazio occupato offline', + 'Free space': 'Spazio libero', + 'Loved tracks': 'Brani preferiti', + 'Favorites': 'Preferiti', + 'All offline tracks': 'Tutte i brani offline', + 'Create new playlist': 'Crea una nuova playlist', + 'Cannot create playlists in offline mode': + 'Impossibile creare playlist in modalità offline', + 'Error': 'Errore', + 'Error logging in! Please check your token and internet connection and try again.': + "Errore durante l'accesso! Controlla il token, la tua connessione ad internet e riprova.", + 'Dismiss': 'Chiudi', + 'Welcome to': 'Benvenuto su', + 'Please login using your Deezer account.': + 'Per favore, esegui il login utilizzando il tuo account Deezer.', + 'Login using browser': 'Login utilizzando il browser', + 'Login using token': 'Login utilizzando il token', + 'Enter ARL': "Inserisci l'ARL", + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Salva', + "If you don't have account, you can register on deezer.com for free.": + 'Se non possiedi un account, puoi registrarti sul sito deezer.com gratuitamente.', + 'Open in browser': 'Apri nel browser', + "By using this app, you don't agree with the Deezer ToS": + 'Utilizzando questa applicazione, non accetti i ToS di Deezer', + 'Play next': 'Riproduci subito dopo', + 'Add to queue': 'Aggiungi alla coda', + 'Add track to favorites': 'Aggiungi il brano ai preferiti', + 'Add to playlist': 'Aggiungi a playlist...', + 'Select playlist': 'Seleziona playlist', + 'Track added to': 'Brano aggiunto a', + 'Remove from playlist': 'Rimuovi dalla playlist', + 'Track removed from': 'Brano rimosso da', + 'Remove favorite': 'Rimuovi dai preferiti', + 'Track removed from library': 'Brano rimosso dalla libreria', + 'Go to': 'Vai a', + 'Make offline': 'Rendi offline', + 'Add to library': 'Aggiungi alla libreria', + 'Remove album': 'Rimuovi album', + 'Album removed': 'Album rimosso', + 'Remove from favorites': 'Rimuovi dai preferiti', + 'Artist removed from library': 'Artista rimosso dalla libreria', + 'Add to favorites': 'Aggiungi ai preferiti', + 'Remove from library': 'Rimuovi dalla libreria', + 'Add playlist to library': 'Aggiungi playlist alla libreria', + 'Added playlist to library': 'Playlist aggiunta alla libreria', + 'Make playlist offline': 'Rendi la playlist offline', + 'Download playlist': 'Scarica playlist', + 'Create playlist': 'Crea playlist', + 'Title': 'Titolo', + 'Description': 'Descrizione', + 'Private': 'Privata', + 'Collaborative': 'Collaborativa', + 'Create': 'Crea', + 'Playlist created!': 'Playlist creata!', + 'Playing from:': 'Riproduzione da:', + 'Queue': 'Coda', + 'Offline search': 'Ricerca offline', + 'Search Results': 'Risultati della ricerca', + 'No results!': 'Nessun risultato!', + 'Show all tracks': 'Mostra tutti i brani', + 'Show all playlists': 'Mostra tutte le playlists', + 'Settings': 'Opzioni', + 'General': 'Generale', + 'Appearance': 'Aspetto', + 'Quality': 'Qualità', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Attuale', + 'Select theme': 'Seleziona Tema', + 'Dark': 'Scuro', + 'Black (AMOLED)': 'Nero (AMOLED)', + 'Deezer (Dark)': 'Deezer (Scuro)', + 'Primary color': 'Colore Principale', + 'Selected color': 'Colore Selezionato', + 'Use album art primary color': + "Usa il colore principale della copertina dell'album", + 'Warning: might be buggy': 'Attenzione: potrebbe causare problemi', + 'Mobile streaming': 'Streaming con dati', + 'Wifi streaming': 'Streaming con WiFi', + 'External downloads': 'Download esterni', + 'Content language': 'Lingua dei contenuti', + 'Not app language, used in headers. Now': + "Non la lingua dell'app, utilizzata negli header. Adesso", + 'Select language': 'Seleziona la lingua', + 'Content country': 'Contenuto dal Paese', + 'Country used in headers. Now': 'Paese contenuto negli header. Ora', + 'Log tracks': 'Log delle tracce', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Invia i log delle canzioni ascoltate a Deezer, abilitalo affinché features come Flow funzionino correttamente', + 'Offline mode': 'Modalità Offline', + 'Will be overwritten on start.': "Sarà sovrascritto all'avvio.", + 'Error logging in, check your internet connections.': + "Errore durante l'accesso, controlla la tua connessione Internet.", + 'Logging in...': 'Accesso in corso...', + 'Download path': 'Percorso di download', + 'Downloads naming': 'Denominazione dei download', + 'Downloaded tracks filename': 'Nome del file dei brani scaricati', + 'Valid variables are': 'Le variabili valide sono', + 'Reset': 'Ripristina', + 'Clear': 'Pulisci', + 'Create folders for artist': 'Crea cartelle per gli artisti', + 'Create folders for albums': 'Crea cartelle per gli album', + 'Separate albums by discs': 'Separa gli album per disco', + 'Overwrite already downloaded files': 'Sovrascrivi i file già scaricati', + 'Copy ARL': 'Copia ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copia userToken / ARL Cookie da utilizzare in altre app.', + 'Copied': 'Copiato', + 'Log out': 'Disconnettiti', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + "A causa dell'incompatibilità del plug-in, l'accesso tramite browser non è disponibile senza riavvio.", + '(ARL ONLY) Continue': '(SOLO ARL) Continua', + 'Log out & Exit': 'Disconnettiti e Esci', + 'Pick-a-Path': 'Scegli un percorso', + 'Select storage': 'Seleziona dispositivo di archiviazione', + 'Go up': 'Vai su', + 'Permission denied': 'Permesso negato', + 'Language': 'Lingua', + 'Language changed, please restart ReFreezer to apply!': + 'Lingua cambiata, riavvia ReFreezer per applicare la modifica!', + 'Importing...': 'Importando...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + 'La traccia non è disponibile su Deezer!', + 'Failed to download track! Please restart.': + 'Impossibile scaricare la traccia! Riavvia.', + 'Storage permission denied!': 'Autorizzazione di archiviazione negata!', + 'Failed': 'Fallito', + 'Queued': 'In coda', + 'External': 'Archiviazione', + 'Restart failed downloads': 'Riavvia download non riusciti', + 'Clear failed': 'Pulisci fallito', + 'Download Settings': 'Impostazioni download', + 'Create folder for playlist': 'Crea cartella per playlist', + 'Download .LRC lyrics': 'Scarica testi .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Non impostato', + 'Search or paste URL': "Cerca o incolla l'URL", + 'History': 'Storia', + 'Download threads': 'Download simultanei', + 'Lyrics unavailable, empty or failed to load!': + 'Testi non disponibili, vuoti o caricamento non riuscito!', + 'About': 'Info', + 'Telegram Channel': 'Canale Telegram', + 'To get latest releases': 'Per ottenere le ultime versioni', + 'Official chat': 'Chat ufficiale', + 'Telegram Group': 'Gruppo Telegram', + 'Huge thanks to all the contributors! <3': + 'Un enorme grazie a tutti i collaboratori! <3', + 'Edit playlist': 'Modifica playlist', + 'Update': 'Aggiorna', + 'Playlist updated!': 'Playlist aggiornata!', + 'Downloads added!': 'Download aggiunti!', + 'Save cover file for every track': + "Salva la copertina dell'album per ogni traccia", + 'Download Log': 'Log dei Download', + 'Repository': 'Archivio', + 'Source code, report issues there.': + 'Codice sorgente, segnala i problemi lì.', + 'Use system theme': 'Utilizza il tema di sistema', + 'Light': 'Chiaro', + 'Popularity': 'Popolarità', + 'User': 'Utente', + 'Track count': 'Contatore tracce', + "If you want to use custom directory naming - use '/' as directory separator.": + "Se desideri usare delle directory personalizzate - usa '/' come separatore per la directory.", + 'Share': 'Condividi', + 'Save album cover': 'Salva la copertina del album', + 'Warning': 'Attenzione', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + "L'uso di troppi download simultanei su dispositivi più vecchi/scarsi potrebbe causare crash!", + 'Create .nomedia files': 'Crea file .nomedia', + 'To prevent gallery being filled with album art': + "Per evitare che la galleria venga riempita con la copertina dell'album", + 'Sleep timer': 'Timer di spegnimento', + 'Minutes:': 'Minuti:', + 'Hours:': 'Ore:', + 'Cancel current timer': 'Annulla il timer corrente', + 'Current timer ends at': 'Il timer attuale termina alle', + 'Smart track list': 'Lista di traccie intelligente', + 'Shuffle': 'Riproduzione casuale', + 'Library shuffle': 'Mischia libreria', + 'Ignore interruptions': 'Ignora interruzioni', + 'Requires app restart to apply!': "Riavvia l'applicazione per applicare!", + 'Ask before downloading': 'Chiedi prima di scaricare', + 'Search history': 'Cronologia delle ricerche', + 'Clear search history': 'Cancella la cronologia delle ricerche', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Accedi per abilitare lo scrobbling.', + 'Login to LastFM': 'Accedi a LastFM', + 'Username': 'Nome utente', + 'Password': 'Password', + 'Login': 'Accedi', + 'Authorization error!': 'Errore di autorizzazione!', + 'Logged out!': 'Disconnesso!', + 'Lyrics': 'Testo', + 'Player gradient background': 'Gradiente di sfondo del lettore', + 'Updates': 'Aggiornamenti', + 'You are running latest version!': "Stai utilizzando l'ultima versione!", + 'New update available!': 'Nuovo aggiornamento disponibile!', + 'Current version: ': 'Versione attuale: ', + 'Unsupported platform!': 'Piattaforma non supportata!', + 'Freezer Updates': 'Aggiornamenti Freezer', + 'Update to latest version in the settings.': + "Aggiorna all'ultima versione nelle impostazioni.", + 'Release date': 'Data di rilascio', + 'Shows': 'Shows', + 'Charts': 'Classifiche', + 'Browse': 'Sfoglia', + 'Quick access': 'Accesso rapido', + 'Play mix': 'Riproduci Mix', + 'Share show': 'Condividi show', + 'Date added': 'Data di aggiunta', + 'Discord': 'Discord', + 'Official Discord server': 'Server Discord ufficiale', + 'Restart of app is required to properly log out!': + "È necessario riavviare l'applicazione per uscire correttamente!", + 'Artist separator': 'Separatore artista', + 'Singleton naming': 'Denominazione del singleton', + 'Keep the screen on': 'Mantieni lo schermo acceso', + 'Wakelock enabled!': 'Wakelock attivato!', + 'Wakelock disabled!': 'Wakelock disattivato!', + 'Show all shows': 'Visualizza tutti gli shows', + 'Episodes': 'Episodi', + 'Show all episodes': 'Visualizza tutti gli episodi', + 'Album cover resolution': 'Risoluzione copertina album', + "WARNING: Resolutions above 1200 aren't officially supported": + 'ATTENZIONE: risoluzioni superiori a 1200x non sono supportate ufficialmente', + 'Album removed from library!': 'Album rimosso dalla libreria!', + 'Remove offline': 'Rimuovi offline', + 'Playlist removed from library!': 'Playlist rimossa dalla libreria!', + 'Blur player background': 'Sfoca lo sfondo del riproduttore', + 'Might have impact on performance': + 'Potrebbe avere un impatto sulle prestazioni', + 'Font': 'Carattere', + 'Select font': 'Seleziona carattere', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Questa app non è progettata per supportare molti caratteri, potrebbero smarginare e corrompere il layout. Usarli a proprio rischio e pericolo!', + 'Enable equalizer': 'Abilita equalizzatore', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Potrebbe abilitare il funzionamento di alcune app di equalizzazione. Richiede il riavvio di Freezer', + 'Visualizer': 'Visualizzatore', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + "Mostra i visualizzatori nella pagina dei testi. ATTENZIONE: Richiede l'autorizzazione del microfono!", + 'Tags': 'Tag', + 'Album': 'Album', + 'Track number': 'Numero traccia', + 'Disc number': 'Numero disco', + 'Album artist': 'Artista album', + 'Date/Year': 'Data/Anno', + 'Label': 'Etichetta', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Traccia il totale', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Testi non sincronizzati', + 'Genre': 'Genere', + 'Contributors': 'Collaboratori', + 'Album art': 'Copertina album', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer non è disponibile nel tuo paese, ReFreezer potrebbe non funzionare correttamente. Utilizza una VPN', + 'Deezer is unavailable': 'Deezer non è disponibile', + 'Continue': 'Continua', + 'Email Login': 'Accesso via e-mail', + 'Email': 'Email', + 'Missing email or password!': 'E-mail o password mancanti!', + 'Error logging in using email, please check your credentials.\nError:': + 'Errore di accesso tramite email, controlla le tue credenziali.\nErrore:', + 'Error logging in!': "Errore durante l'accesso!", + 'Change display mode': 'Cambia modalità di visualizzazione', + 'Enable high refresh rates': 'Attiva frequenze di aggiornamento elevate', + 'Display mode': 'Modalità schermo', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importa playlist Spotify fino a 100 tracce senza alcun accesso.', + 'Download imported tracks': 'Scarica le tracce importate', + 'Start import': "Avvia l'importazione", + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Importa qualsiasi playlist Spotify, importa dalla propria libreria Spotify. Richiede un account gratuito.', + 'Spotify Importer v2': 'Importatore Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Questo importatore richiede Spotify Client ID e Client Secret. Per ottenerli:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + "1. Vai a: developer.spotify.com/dashboard e crea un'app.", + 'Open in Browser': 'Apri nel browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + "2. Nell'app che hai appena creato vai alle impostazioni e imposta l'URL di reindirizzamento a: ", + 'Copy the Redirect URL': "Copia l'URL di reindirizzamento", + 'Client ID': 'ID Client', + 'Client Secret': 'Client Secret', + 'Authorize': 'Autorizza', + 'Logged in as: ': 'Accesso effettuato come: ', + 'Import playlists by URL': 'Importa playlist da URL', + 'URL': 'URL', + 'Options': 'Opzioni', + 'Invalid/Unsupported URL': 'URL non valido/non supportato', + 'Please wait...': 'Attendere prego...', + 'Login using email': 'Accedi tramite email', + 'Track removed from offline!': 'Traccia rimossa da offline!', + 'Removed album from offline!': 'Album rimosso da offline!', + 'Playlist removed from offline!': 'Playlist rimossa da offline!', + 'Repeat': 'Ripeti', + 'Repeat one': 'Ripeti una volta', + 'Repeat off': 'Ripeti Off', + 'Love': 'Amore', + 'Unlove': 'Non amare', + 'Dislike': 'Non mi piace', + 'Close': 'Chiudi', + 'Sort playlist': 'Ordina playlist', + 'Sort ascending': 'Ordinamento crescente', + 'Sort descending': 'Ordinamento decrescente', + 'Stop': 'Stop', + 'Start': 'Avvia', + 'Clear all': 'Cancella Tutto', + 'Play previous': 'Riproduci precedente', + 'Play': 'Play', + 'Pause': 'Pausa', + 'Remove': 'Rimuovi', + 'Seekbar': 'Seekbar', + 'Singles': 'Singoli', + 'Featured': 'In evidenza', + 'Fans': 'Fans', + 'Duration': 'Durata', + 'Sort': 'Ordina', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Il tuo ARL potrebbe essere scaduto, prova a disconnetterti e ad accedere usando un nuovo ARL o browser.' + }, + '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': '테마 선택', + '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 ReFreezer to apply!': + '언어가 변경되었습니다. 적용하려면 Freezer를 다시 시작하세요!', + 'Importing...': '…\$가져 오는 중', + 'Radio': '라디오', + 'Flow': '플로우', + 'Track is not available on Deezer!': 'Deezer에서는 트랙을 사용할 수 없습니다!', + 'Failed to download track! Please restart.': '트랙을 다운로드하지 못했습니다! 다시 시작하십시오.', + 'Storage permission denied!': '저장공간 권한 거부됨', + 'Failed': '실패', + 'Queued': '대기열에 추가됨', + 'External': '저장소', + 'Restart failed downloads': '실패한 다운로드 재시작', + 'Clear failed': '지우기 실패', + 'Download Settings': '다운로드 설정', + 'Create folder for playlist': '플레이리스트 용 폴더 만들기', + 'Download .LRC lyrics': '.LRC 가사 파일 다운로드', + 'Proxy': '프록시', + 'Not set': '설정되지 않음', + 'Search or paste URL': '검색 또는 URL 입력', + 'History': '최근 기록', + 'Download threads': '현재 다운로드 목록', + 'Lyrics unavailable, empty or failed to load!': + '가사 없거나, 미지원 혹은 로딩에 실패하였습니다!', + 'About': '정보', + 'Telegram Channel': '텔레그램 채널', + 'To get latest releases': '최신 버전 받기', + 'Official chat': '공식 채팅방', + 'Telegram Group': '텔레그램 그룹', + 'Huge thanks to all the contributors! <3': '도움을 주신 모든 분들께 감사드립니다! <3', + 'Edit playlist': '재생 목록 편집', + 'Update': '업데이트', + 'Playlist updated!': '재생목록 저장됨!', + 'Downloads added!': '다운로드 추가됨!', + 'Save cover file for every track': '모든 트랙의 앨범커버 저장', + 'Download Log': '다운로드 기록', + 'Repository': '리포지토리', + 'Source code, report issues there.': '소스 코드, 문제 보고는 여기에', + 'Use system theme': '시스템 테마 사용하기', + 'Light': '밝은 테마', + 'Popularity': '인기순', + 'User': '사용자', + 'Track count': '트랙 카운팅', + "If you want to use custom directory naming - use '/' as directory separator.": + "다운로드 위치 직접 설정 시 '/' 로 디렉터리를 구분해주세요.", + 'Share': '공유', + 'Save album cover': '앨범 커버 저장', + 'Warning': '경고', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + '오래되거나 취약한 기기에서 동시 다운로드 시 충돌이 일어날 수 있습니다!', + 'Create .nomedia files': '.nomedia 파일 생성', + 'To prevent gallery being filled with album art': '갤러리에 앨범 커버가 나타나지 않게하기', + 'Sleep timer': '취침 타이머', + 'Minutes:': '분', + 'Hours:': '시간', + 'Cancel current timer': '현재 타이머 취소', + 'Current timer ends at': '타이머 종료 시간', + 'Smart track list': '스마트 트랙 목록', + 'Shuffle': '랜덤재생', + 'Library shuffle': '라이브러리 랜덤 재생', + 'Ignore interruptions': '벙해 무시하기', + 'Requires app restart to apply!': '변경사항을 적용하려면 재시작이 필요합니다!', + 'Ask before downloading': '다운로드 전에 묻기', + 'Search history': '검색 기록', + 'Clear search history': '검색 기록 지우기', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': '스크로블링 활성화를 위하여 로그인 해주세요', + 'Login to LastFM': 'LastFM에 로그인', + 'Username': '아이디', + 'Password': '비밀번호', + 'Login': '로그인', + 'Authorization error!': '인증 오류', + 'Logged out!': '로그아웃됨', + 'Lyrics': '가사', + 'Player gradient background': '플레이어 그라데이션 배경', + 'Updates': '업데이트', + 'You are running latest version!': '최신 버전을 사용 중입니다', + 'New update available!': '사용가능한 업데이트가 있습니다.', + 'Current version: ': '현재 버전: ', + 'Unsupported platform!': '지원되지 않는 플랫폼', + 'Freezer Updates': 'Freezer 업데이트', + 'Update to latest version in the settings.': '설정에서 최신버전으로 업데이트 하십시오.', + 'Release date': '출시일', + 'Shows': '팟캐스트', + 'Charts': '차트', + 'Browse': '탐색', + 'Quick access': '바로가기', + 'Play mix': 'Mix 재생', + 'Share show': '팟캐스트 공유', + 'Date added': '추가된 날짜', + 'Discord': 'Discord', + 'Official Discord server': '공식 Discord 서버', + 'Restart of app is required to properly log out!': + '제대로 로그아웃하기 위해 앱 재시작이 필요합니다!', + 'Artist separator': '아티스트 구분 방법', + 'Singleton naming': '단일 다운로드 네이밍', + 'Keep the screen on': '화면 켜진 상태 유지', + 'Wakelock enabled!': 'Wakelock이 활성화 되었습니다!', + 'Wakelock disabled!': 'Wakelock이 비활성화 되었습니다!', + 'Show all shows': '모든 팟캐스트 표시', + 'Episodes': '에피소드', + 'Show all episodes': '모든 에피소드 표시', + 'Album cover resolution': '앨범 커버 화질', + "WARNING: Resolutions above 1200 aren't officially supported": + '경고: 1200 이상의 화질은 공식적으로 지원되지 않습니다', + 'Album removed from library!': '앨범이 라이브러리에서 삭제되었습니다!', + 'Remove offline': '오프라인 트랙 제거', + 'Playlist removed from library!': '라이브러리에서 재생목록이 삭제되었습니다!', + 'Blur player background': 'Blur player background', + 'Might have impact on performance': 'Might have impact on performance', + 'Font': 'Font', + 'Select font': 'Select font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!", + 'Enable equalizer': 'Enable equalizer', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Might enable some equalizer apps to work. Requires restart of Freezer', + 'Visualizer': 'Visualizer', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Track number', + 'Disc number': 'Disc number', + 'Album artist': 'Album artist', + 'Date/Year': 'Date/Year', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Track total', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Unsynchronized lyrics', + 'Genre': 'Genre', + 'Contributors': 'Contributors', + 'Album art': 'Album art', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'fa_ir': { + 'Home': 'صفحه اصلی', + 'Search': 'جست‌وجو', + 'Library': 'مجموعه', + "Offline mode, can't play flow or smart track lists.": + 'حالت آفلاین، نمی‌توانید از حالت پیشنهاد قطعه‌ی بعدی یا پخش قطعات هوشمند استفاده کنید.', + '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': + 'در حال حاضر فقط اسپاتیفای با محدودیت ۱۰۰ قطعه پشتیبانی میشود', + 'Due to API limitations': 'به خاطر ناسازگاری', + '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': 'ARL وارد کردن', + 'Token (ARL)': 'توکِن (ARL)', + 'Save': 'ذخیره', + "If you don't have account, you can register on deezer.com for free.": + 'اگر حساب کاربری در دیزر ندارید، میتوانید به صورت رایگان در سایتش ثبت نام کنید.', + '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': 'تم انتخابی', + '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': + 'ارسال گوش داده های ثبت شده به دیزر امکاناتی مثل جریان پخش را فراهم می‌سازد ', + '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.': + 'کپی کردن توکن یا کوکی حساب برای استفاده در برنامه ای دیگر', + '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 ReFreezer to apply!': + 'زبان عوض شد، لطفاً فریزر را ریستارت کنید', + 'Importing...': 'وارد کردن...', + 'Radio': 'رادیو', + 'Flow': 'جریان', + 'Track is not available on Deezer!': 'قطعه در دیزر موجود نمی‌باشد!', + 'Failed to download track! Please restart.': + 'ناموفق در بارگیری قطعه! لطفاً دوباره تلاش کنید', + 'Storage permission denied!': 'مجوز ذخیره ساز داده نشد', + 'Failed': 'نا موفق', + 'Queued': 'در صف انتظار', + 'External': 'خارجی', + 'Restart failed downloads': 'از سرگیری بارگیری های ناموفق', + 'Clear failed': 'پاکسازی ناموفق ها', + 'Download Settings': 'تنظیمات بارگیری', + 'Create folder for playlist': 'برای لیست پخش پوشه بساز', + 'Download .LRC lyrics': 'بارگیری اشعار .LRC', + 'Proxy': 'پراکسی', + 'Not set': 'تنظیم نشده', + 'Search or paste URL': 'جستجو یا نشاندن لینک', + 'History': 'تاریخچه', + 'Download threads': 'رشته های بارگیری', + 'Lyrics unavailable, empty or failed to load!': + 'اشعار وجود ندارد، خالی یا ناموفق در بارگذاری', + 'About': 'درباره', + 'Telegram Channel': 'کانال تلگرام', + 'To get latest releases': 'برای دریافت آخرین نسخه ها', + 'Official chat': 'گروه رسمی', + 'Telegram Group': 'گروه تلگرامی', + 'Huge thanks to all the contributors! <3': + 'با تشکر فراوان از همه مشارکت کننده ها', + 'Edit playlist': 'ویرایش لیست پخش', + 'Update': 'به روز رسانی', + 'Playlist updated!': 'لیست پخش به روز رسانی شد!', + 'Downloads added!': 'بارگیری ها اضافه شدند', + 'Save cover file for every track': 'ذخیره فایل کاور برای تک تک قطعه ها', + 'Download Log': 'وقایع بارگیری ها', + 'Repository': 'مخزن', + 'Source code, report issues there.': 'کد منبع، مشکلات را آنجا گزارش کنید', + 'Use system theme': 'استفاده از تم سیستم', + 'Light': 'روشن', + 'Popularity': 'محبوبیت', + 'User': 'نام کاربری', + 'Track count': 'تعداد قطعه', + "If you want to use custom directory naming - use '/' as directory separator.": + "اگر می‌خواهید مسیر ذخیره سازی شخصی بسازید، از '/' به عنوان جدا کننده استفاده کنید.", + 'Share': 'اشتراک', + 'Save album cover': 'ذخیره فایل کاور آلبوم', + 'Warning': 'اخطار', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'بارگیری همزمان بیش از حد باعث مشکل یا کرش کردن دستگاه های قدیمی یا ضعیف میشود!', + 'Create .nomedia files': 'ایجاد فایل های .nomedia', + 'To prevent gallery being filled with album art': + 'برای جلوگیری از پر شدن گالری از تصاویر کاور', + 'Sleep timer': 'زمان سنج خواب', + 'Minutes:': 'دقیقه:', + 'Hours:': 'ساعت:', + 'Cancel current timer': 'لغو زمان سنج فعلی', + 'Current timer ends at': 'زمان سنج فعلی تمام می‌شود در', + 'Smart track list': 'لیست قطعات هوشمند', + 'Shuffle': 'پخش تصادفی', + 'Library shuffle': 'پخش تصادفی مجموعه', + 'Ignore interruptions': 'نادیده گرفتن مزاحمت ها', + 'Requires app restart to apply!': + 'برای اعمال تغییرات اجرای دوباره برنامه نیاز است!', + 'Ask before downloading': 'پرسش قبل از بارگیری', + 'Search history': 'تاریخچه جستجو', + 'Clear search history': 'پاک کردن تاریخچه جستجو', + 'LastFM': 'لست اف ام', + 'Login to enable scrobbling.': 'برای فعال سازی اسکراب لطفاً وارد شوید', + 'Login to LastFM': 'وارد شدن به حساب لست اف ام', + 'Username': 'نام کاربری', + 'Password': 'رمز', + 'Login': 'وارد شدن', + 'Authorization error!': 'اخطار در هماهنگی', + 'Logged out!': 'از حساب خارج شد', + 'Lyrics': 'اشعار', + 'Player gradient background': 'شیب پشت ضمینه پخش کننده', + 'Updates': 'به روز رسانی ها', + 'You are running latest version!': 'آخرین نسخه در حال استفاده است', + 'New update available!': 'به روز رسانی جدید در دسترس است', + 'Current version: ': 'نسخه ی فعلی : ', + 'Unsupported platform!': 'پلتفرم پشتیبانی نشده', + 'Freezer Updates': 'به روز رسانی های فریزر', + 'Update to latest version in the settings.': + 'از تنظیمات به روز رسانی کنید.', + 'Release date': 'زمان عرضه', + 'Shows': 'اجرا ها', + 'Charts': 'رده بندی ها', + 'Browse': 'مرور', + 'Quick access': 'دسترسی سریع', + 'Play mix': 'پخش ترکیبی', + 'Share show': 'به اشتراک گذاشتن اجرا', + 'Date added': 'زمان اضافه شده', + 'Discord': 'دیسکورد', + 'Official Discord server': 'سرور رسمی دیسکورد', + 'Restart of app is required to properly log out!': + 'برای خارج شدن کامل از حساب ریستارت برنامه لازم است', + 'Artist separator': 'جدا کننده صاحب اثر', + 'Singleton naming': 'اسم گذاری تکی', + 'Keep the screen on': 'صفحه نمایش را روشن نگه دار', + 'Wakelock enabled!': 'محافظ قفل صفحه فعال شد', + 'Wakelock disabled!': 'محافظ قفل صفحه غیرفعال شد', + 'Show all shows': 'همه اجراها را نشان بده', + 'Episodes': 'قسمت ها', + 'Show all episodes': 'همه ی قسمت ها را نشان بده', + 'Album cover resolution': 'رزولوشن تصویر آلبوم', + "WARNING: Resolutions above 1200 aren't officially supported": + 'اخطار: رزولوشن بالاتر از ۱۲۰۰ به صورت رسمی پشتیبانی نمیشود', + 'Album removed from library!': 'آلبوم از مجموعه حذف شد', + 'Remove offline': 'آفلاین رو پاک کن', + 'Playlist removed from library!': 'لیست پخش از مجموعه حذف شد', + 'Blur player background': 'تیره کردن پشت ضمیمه پخش کننده', + 'Might have impact on performance': 'ممکن است باعث کندی عملکرد شود', + 'Font': 'فونت', + 'Select font': 'انتخاب فونت', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'این برنامه برای پشتیبانی از فونت های زیاد ساخته نشده است، ممکن است باعث به هم ریختن چینش و صفحات شود. مسئولیت بر عهده خودتان است!', + 'Enable equalizer': 'روشن کردن اکولایزر', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'باعث فعال شدن برخی از برنامه های اکولایزر میشود. نیازمند ریستارت کردن است', + 'Visualizer': 'جلوه های تصویری', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'نمایش جلوه های تصویری در صفحه ی اشعار. اخطار: نیازمند دسترسی به میکروفون!', + 'Tags': 'برچسب ها', + 'Album': 'آلبوم', + 'Track number': 'شماره قطعه', + 'Disc number': 'شماره ديسک', + 'Album artist': 'صاحب اثر آلبوم', + 'Date/Year': 'زمان/سال', + 'Label': 'منتشر کننده', + 'ISRC': 'ISRC', + 'UPC': 'کد جهانی محصول', + 'Track total': 'مجموع قطعه ها', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'اشعار ناهماهنگ', + 'Genre': 'ژانر', + 'Contributors': 'مشارکت کنندگان', + 'Album art': 'تصویر آلبوم', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'دیزر در کشور شما قابل دسترس نیست، ممکن است فریزر به درستی کار نکند، لطفاً از وی پی ان استفاده کنید', + 'Deezer is unavailable': 'دیزر در دسترس نیست', + 'Continue': 'ادامه', + 'Email Login': 'وارد شدن با ایمیل', + 'Email': 'ایمیل', + 'Missing email or password!': 'پست الکترونیکی یا گذرواژه وجود ندارد', + 'Error logging in using email, please check your credentials.\nError:': + 'اخطار هنگام ورود با ایمیل، لطفاً مشخصات را دوباره بررسی کنید.\nاخطار:', + 'Error logging in!': 'اخطار در هنگام ورود', + 'Change display mode': 'تغییر حالت نمایش', + 'Enable high refresh rates': 'فعال سازی به روز رسانی صفحه ی بالا', + 'Display mode': 'حالت نمایش', + 'Spotify v1': 'اسپاتیفای نسخه ی ۱', + 'Import Spotify playlists up to 100 tracks without any login.': + 'وارد کردن لیست پخش اسپاتیفای تا 100 قطعه بدون نیاز به وارد شدن به اکانت', + 'Download imported tracks': 'بارگیری قطعه‌هاي وارد شده', + 'Start import': 'آغاز وارد کردن', + 'Spotify v2': 'اسپاتیفای نسخه ی 2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'وارد کردن هرگونه لیست پخش از اسپاتیفای، وارد کردن از لیست شخصی، نیازمند حساب رایگان', + 'Spotify Importer v2': 'وارد کننده اسپایتفای ورژن 2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'وارد کننده نیازمند آی دی و سیکرت کلاینت اسپاتیفای میباشد، برای به دست آوردن:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. به مسیر: developer.spotify.com/dashboard بروید و یک برنامه بسازید', + 'Open in Browser': 'باز کردن در مرورگر', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + 'در برنامه ای که ساختید به بخش تنظیمات مراجعه کنید و لینک میانبر را به این تنظیم کنید: ', + 'Copy the Redirect URL': 'کپی لینک میانبر', + 'Client ID': 'آی دی کلاینت', + 'Client Secret': 'سیکرت کلاینت', + 'Authorize': 'تجویز', + 'Logged in as: ': 'وارد شده با: ', + 'Import playlists by URL': 'وارد کردن لیست پخش بوسیله لینک', + 'URL': 'لینک', + 'Options': 'گزینه ها', + 'Invalid/Unsupported URL': 'لینک اشتباه/پشتیبانی نشده', + 'Please wait...': 'لطفا صبر کنید...', + 'Login using email': 'ورود به وسیلۀ ایمیل', + 'Track removed from offline!': 'قطعه از آفلاین حذف شد', + 'Removed album from offline!': 'آلبوم از آفلاین حذف شد', + 'Playlist removed from offline!': 'لیست پخش از آفلاین حذف شد', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'pl_pl': { + 'Home': 'Strona główna', + 'Search': 'Szukaj', + 'Library': 'Biblioteka', + "Offline mode, can't play flow or smart track lists.": + 'Tryb offline, nie można odtworzyć Flow ani inteligentnej listy utworów.', + 'Added to library': 'Dodane do biblioteki', + 'Download': 'Pobierz', + 'Disk': 'Dysk', + 'Offline': 'Offline', + 'Top Tracks': 'Najlepsze utwory', + 'Show more tracks': 'Pokaż więcej utworów', + 'Top': 'Najlepsze', + 'Top Albums': 'Najlepsze Albumy', + 'Show all albums': 'Pokaż wszystkie albumy', + 'Discography': 'Dyskografia', + 'Default': 'Domyślne', + 'Reverse': 'Odwróć', + 'Alphabetic': 'Alfabetycznie', + 'Artist': 'Wykonawca', + 'Post processing...': 'Przetwarzanie końcowe...', + 'Done': 'Gotowe', + 'Delete': 'Usuń', + 'Are you sure you want to delete this download?': + 'Czy na pewno chcesz usunąć to pobranie?', + 'Cancel': 'Anuluj', + 'Downloads': 'Pobrane', + 'Clear queue': 'Wyczyść kolejkę', + "This won't delete currently downloading item": + 'To nie usunie aktualnie pobieranego elementu', + 'Are you sure you want to delete all queued downloads?': + 'Czy na pewno chcesz usunąć wszystkie pobrania w kolejce?', + 'Clear downloads history': 'Wyczyść historię pobieranych plików', + 'WARNING: This will only clear non-offline (external downloads)': + 'OSTRZEŻENIE: To wyczyści tylko nie-offline (zewnętrzne pobierania)', + 'Please check your connection and try again later...': + 'Proszę sprawdź swoje połączenie internetowe i spróbuj ponownie później...', + 'Show more': 'Pokaż więcej', + 'Importer': 'Importer', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Obecnie obsługuje tylko Spotify, z limitem 100 utworów', + 'Due to API limitations': 'Ze względu na ograniczenia API', + 'Enter your playlist link below': 'Wprowadź link do playlisty poniżej', + 'Error loading URL!': 'Błąd ładowania URL!', + 'Convert': 'Konwertuj', + 'Download only': 'Tylko pobrane', + 'Downloading is currently stopped, click here to resume.': + 'Pobieranie jest obecnie zatrzymane, kliknij tutaj, aby wznowić.', + 'Tracks': 'Utwory', + 'Albums': 'Albumy', + 'Artists': 'Wykonawcy', + 'Playlists': 'Playlisty', + 'Import': 'Importuj', + 'Import playlists from Spotify': 'Importuj playlisty ze Spotify', + 'Statistics': 'Statystyki', + 'Offline tracks': 'Utwory offline', + 'Offline albums': 'Albumy offline', + 'Offline playlists': 'Playlisty offline', + 'Offline size': 'Rozmiar w trybie offline', + 'Free space': 'Wolne miejsce', + 'Loved tracks': 'Ulubione utwory', + 'Favorites': 'Ulubione', + 'All offline tracks': 'Wszystkie utwory offline', + 'Create new playlist': 'Utwórz nową playlistę', + 'Cannot create playlists in offline mode': + 'Nie można utworzyć playlist w trybie offline', + 'Error': 'Błąd', + 'Error logging in! Please check your token and internet connection and try again.': + 'Błąd podczas logowania! Sprawdź swój token i połączenie internetowe i spróbuj ponownie.', + 'Dismiss': 'Zamknij', + 'Welcome to': 'Witamy w', + 'Please login using your Deezer account.': + 'Zaloguj się za pomocą konta Deezer.', + 'Login using browser': 'Zaloguj się za pomocą przeglądarki', + 'Login using token': 'Logowanie przy użyciu tokenu', + 'Enter ARL': 'Wprowadź ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Zapisz', + "If you don't have account, you can register on deezer.com for free.": + 'Jeśli nie masz konta, możesz zarejestrować się na deezer.com za darmo.', + 'Open in browser': 'Otwórz w przeglądarce', + "By using this app, you don't agree with the Deezer ToS": + 'Używając tej aplikacji, nie zgadzasz się z ToS Deezer', + 'Play next': 'Odtwarzaj następne', + 'Add to queue': 'Dodaj do kolejki', + 'Add track to favorites': 'Dodaj do ulubionych', + 'Add to playlist': 'Dodaj do playlisty', + 'Select playlist': 'Wybierz playlistę', + 'Track added to': 'Utwór dodany do', + 'Remove from playlist': 'Usuń z playlisty', + 'Track removed from': 'Utwór usunięty z', + 'Remove favorite': 'Usuń ulubione', + 'Track removed from library': 'Utwór usunięty z biblioteki', + 'Go to': 'Idź do', + 'Make offline': 'Zrób offline', + 'Add to library': 'Dodaj do biblioteki', + 'Remove album': 'Usuń album', + 'Album removed': 'Album usunięty', + 'Remove from favorites': 'Usuń z ulubionych', + 'Artist removed from library': 'Artysta usunięty z biblioteki', + 'Add to favorites': 'Dodaj do ulubionych', + 'Remove from library': 'Usuń z biblioteki', + 'Add playlist to library': 'Dodaj playlistę do biblioteki', + 'Added playlist to library': 'Dodano playlistę do biblioteki', + 'Make playlist offline': 'Uczyń playlistę offline', + 'Download playlist': 'Pobierz playlistę', + 'Create playlist': 'Utwórz playlistę', + 'Title': 'Tytuł', + 'Description': 'Opis', + 'Private': 'Prywatny', + 'Collaborative': 'Współpracujący', + 'Create': 'Utwórz', + 'Playlist created!': 'Playlista utworzona!', + 'Playing from:': 'Odtwarzanie z:', + 'Queue': 'Kolejka', + 'Offline search': 'Wyszukiwanie w trybie offline', + 'Search Results': 'Wyniki wyszukiwania', + 'No results!': 'Brak wyników!', + 'Show all tracks': 'Pokaż wszystkie utwory', + 'Show all playlists': 'Pokaż wszystkie playlisty', + 'Settings': 'Ustawienia', + 'General': 'Ogólne', + 'Appearance': 'Wygląd', + 'Quality': 'Jakość', + 'Deezer': 'Deezer', + 'Theme': 'Motyw', + 'Currently': 'Obecnie', + 'Select theme': 'Wybierz motyw', + 'Dark': 'Ciemny', + 'Black (AMOLED)': 'Czarny (AMOLED)', + 'Deezer (Dark)': 'Deezer (ciemny)', + 'Primary color': 'Kolor podstawowy', + 'Selected color': 'Wybrany kolor', + 'Use album art primary color': 'Użyj podstawowego koloru okładki albumu', + 'Warning: might be buggy': 'Ostrzeżenie: może zawierać błędny', + 'Mobile streaming': 'Strumieniowanie (dane komórkowe)', + 'Wifi streaming': 'Strumieniowanie (WiFi)', + 'External downloads': 'Pobrane z zewnątrz', + 'Content language': 'Język treści', + 'Not app language, used in headers. Now': + 'To nie jest język aplikacji, używany w nagłówkach. Teraz', + 'Select language': 'Wybierz język', + 'Content country': 'Kraj zawartości', + 'Country used in headers. Now': 'Kraj używany w nagłówkach. Teraz', + 'Log tracks': 'Rejestr utworów', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Wyślij dzienniki słuchania ścieżek do Deezer, włącz aby funkcje takie jak Flow działały poprawnie', + 'Offline mode': 'Tryb offline', + 'Will be overwritten on start.': 'Zostanie nadpisany przy starcie.', + 'Error logging in, check your internet connections.': + 'Błąd logowania, sprawdź swoje połączenia internetowe.', + 'Logging in...': 'Logowanie...', + 'Download path': 'Ścieżka pobierania', + 'Downloads naming': 'Nazewnictwo pobieranych', + 'Downloaded tracks filename': 'Nazwa pliku pobranego utworu', + 'Valid variables are': 'Prawidłowe zmienne to', + 'Reset': 'Reset', + 'Clear': 'Wyczyść', + 'Create folders for artist': 'Utwórz foldery dla wykonawcy', + 'Create folders for albums': 'Utwórz foldery dla albumów', + 'Separate albums by discs': 'Oddziel albumy po dyskach', + 'Overwrite already downloaded files': 'Nadpisz już pobrane pliki', + 'Copy ARL': 'Kopiuj ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Skopiuj userToken/ARL Cookie do użycia w innych aplikacjach.', + 'Copied': 'Skopiowane', + 'Log out': 'Wyloguj się', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Ze względu na niekompatybilność wtyczki, logowanie za pomocą przeglądarki jest niedostępne bez restartu.', + '(ARL ONLY) Continue': '(TYLKO ARL) Kontynuuj', + 'Log out & Exit': 'Wyloguj się i wyjdź', + 'Pick-a-Path': 'Wybierz ścieżkę', + 'Select storage': 'Wybierz pamięć', + 'Go up': 'Idź do góry', + 'Permission denied': 'Odmowa uprawnień', + 'Language': 'Język', + 'Language changed, please restart ReFreezer to apply!': + 'Zmieniono język, uruchom ponownie ReFreezer aby zastosować!', + 'Importing...': 'Importowanie...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Ścieżka nie jest dostępna w Deezer!', + 'Failed to download track! Please restart.': + 'Nie udało się pobrać utworu! Proszę uruchomić ponownie.', + 'Storage permission denied!': 'Odmowa dostępu do pamięci!', + 'Failed': 'Niepowodzenie', + 'Queued': 'W kolejce', + 'External': 'Pamięć', + 'Restart failed downloads': 'Zrestartuj nieudane pobieranie', + 'Clear failed': 'Wyczyść nieudane', + 'Download Settings': 'Ustawienia pobierania plików', + 'Create folder for playlist': 'Utwórz folder dla playlisty', + 'Download .LRC lyrics': 'Pobierz tekst .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Nie ustawiono', + 'Search or paste URL': 'Szukaj lub wklej URL', + 'History': 'Historia', + 'Download threads': 'Aktualne pobierania', + 'Lyrics unavailable, empty or failed to load!': + 'Tekst jest niedostępny, pusty lub nie można go załadować!', + 'About': 'O aplikacji', + 'Telegram Channel': 'Kanał Telegram', + 'To get latest releases': 'Aby uzyskać najnowsze wersje', + 'Official chat': 'Oficjalny czat', + 'Telegram Group': 'Grupa na Telegramie', + 'Huge thanks to all the contributors! <3': + 'Ogromne podziękowania dla wszystkich współtwórców! <3', + 'Edit playlist': 'Edytuj playlistę', + 'Update': 'Aktualizuj', + 'Playlist updated!': 'Playlista zaktualizowana!', + 'Downloads added!': 'Dodano pobierania!', + 'Save cover file for every track': 'Zapisz okładkę dla każdego utworu', + 'Download Log': 'Dziennik pobierania', + 'Repository': 'Repozytorium', + 'Source code, report issues there.': 'Kod źródłowy, zgłaszaj tam problemy.', + 'Use system theme': 'Użyj motywu systemowego', + 'Light': 'Jasny', + 'Popularity': 'Popularność', + 'User': 'Użytkownik', + 'Track count': 'Liczba utworów', + "If you want to use custom directory naming - use '/' as directory separator.": + "Jeśli chcesz użyć niestandardowej nazwy katalogu - użyj '/' jako separatora katalogów.", + 'Share': 'Udostępnij', + 'Save album cover': 'Zapisz okładkę albumu', + 'Warning': 'Ostrzeżenie', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Używanie zbyt wielu równoczesnych pobrań na starszych/słabszych urządzeniach może spowodować awarię!', + 'Create .nomedia files': 'Utwórz pliki .nomedia', + 'To prevent gallery being filled with album art': + 'Aby zapobiec wypełnieniu galerii okładką albumu', + 'Sleep timer': 'Wyłącznik czasowy', + 'Minutes:': 'Minuty:', + 'Hours:': 'Godziny:', + 'Cancel current timer': 'Anuluj wyłącznik', + 'Current timer ends at': 'Bieżący timer kończy się o', + 'Smart track list': 'Inteligentna lista utworów', + 'Shuffle': 'Losowo', + 'Library shuffle': 'Odtwarzaj losowo bibliotekę', + 'Ignore interruptions': 'Ignoruj przerywanie odtwarzania', + 'Requires app restart to apply!': + 'Wymaga ponownego uruchomienia aplikacji!', + 'Ask before downloading': 'Zapytaj przed pobraniem', + 'Search history': 'Historia wyszukiwania', + 'Clear search history': 'Wyczyść historię wyszukiwania', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Zaloguj się, aby włączyć scrobbling.', + 'Login to LastFM': 'Zaloguj się do LastFM', + 'Username': 'Nazwa użytkownika', + 'Password': 'Hasło', + 'Login': 'Zaloguj', + 'Authorization error!': 'Błąd autoryzacji!', + 'Logged out!': 'Wylogowano!', + 'Lyrics': 'Tekst', + 'Player gradient background': 'Gradient tła odtwarzacza', + 'Updates': 'Aktualizacje', + 'You are running latest version!': 'Używasz najnowszej wersji!', + 'New update available!': 'Dostępna jest nowa aktualizacja!', + 'Current version: ': 'Zainstalowana wersja: ', + 'Unsupported platform!': 'Nieobsługiwana platforma!', + 'Freezer Updates': 'Aktualizacje Freezer', + 'Update to latest version in the settings.': + 'Zaktualizuj do najnowszej wersji w ustawieniach.', + 'Release date': 'Data wydania', + 'Shows': 'Podcasty', + 'Charts': 'Na czasie', + 'Browse': 'Przeglądaj', + 'Quick access': 'Szybki dostęp', + 'Play mix': 'Odtwarzaj składankę', + 'Share show': 'Udostępnij podcast', + 'Date added': 'Data dodania', + 'Discord': 'Discord', + 'Official Discord server': 'Oficjalny serwer Discord', + 'Restart of app is required to properly log out!': + 'Ponowne uruchomienie aplikacji jest wymagane do poprawnego wylogowania!', + 'Artist separator': 'Separator dla wielu artystów', + 'Singleton naming': 'Nazewnictwo singli', + 'Keep the screen on': 'Pozostaw włączony ekran', + 'Wakelock enabled!': 'Wybudzanie aplikacji włączone!', + 'Wakelock disabled!': 'Wybudzanie aplikacji wyłączone!', + 'Show all shows': 'Pokazuj wszystkie programy', + 'Episodes': 'Odcinki', + 'Show all episodes': 'Pokaż wszystkie odcinki', + 'Album cover resolution': 'Rozdzielczość okładki albumu', + "WARNING: Resolutions above 1200 aren't officially supported": + 'OSTRZEŻENIE: Rezolucje powyżej 1200 nie są oficjalnie wspierane', + 'Album removed from library!': 'Album usunięty z biblioteki!', + 'Remove offline': 'Usuń zapis offline', + 'Playlist removed from library!': 'Playlista usunięta z biblioteki!', + 'Blur player background': 'Rozmyte tło odtwarzacza', + 'Might have impact on performance': 'Może mieć wpływ na wydajność', + 'Font': 'Czcionka', + 'Select font': 'Wybierz czcionkę', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Ta aplikacja nie jest stworzona do obsługi wielu czcionek, może zniszczyć układ i spowodować przepełnienie. Używaj na własne ryzyko!', + 'Enable equalizer': 'Włącz korektor dźwięku', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Może sprawić, że niektóre aplikacje do korekty dźwięku zaczną działać. Wymaga ponownego uruchomienia Freezera', + 'Visualizer': 'Wizualizator', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Pokaż wizualizatorów na stronie tekstowej. UWAGA: Wymaga uprawnienia mikrofonu!', + 'Tags': 'Tagi', + 'Album': 'Album', + 'Track number': 'Numer utworu', + 'Disc number': 'Numer dysku', + 'Album artist': 'Wykonawca albumu', + 'Date/Year': 'Data/Rok', + 'Label': 'Etykieta', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Ilość utworów', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Niezsynchronizowany tekst', + 'Genre': 'Gatunek', + 'Contributors': 'Współautorzy', + 'Album art': 'Okładka albumu', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer nie jest dostępny w twoim kraju, ReFreezer może nie działać prawidłowo. Użyj VPN', + 'Deezer is unavailable': 'Deezer jest niedostępny', + 'Continue': 'Kontynuuj', + 'Email Login': 'Logowanie przez e-mail', + 'Email': 'Email', + 'Missing email or password!': 'Brakujący adres email lub hasło!', + 'Error logging in using email, please check your credentials.\nError:': + 'Błąd podczas logowania przez email, sprawdź dane logowania.\nBłąd:', + 'Error logging in!': 'Błąd podczas logowania!', + 'Change display mode': 'Zmień widok', + 'Enable high refresh rates': 'Włącz wysoką częstotliwość odświeżania', + 'Display mode': 'Tryb wyświetlania', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importuj playlisty Spotify do 100 utworów bez żadnego logowania.', + 'Download imported tracks': 'Pobierz zaimportowane utwory', + 'Start import': 'Rozpocznij importowanie', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Zaimportuj dowolną playlistę Spotify ze swojej biblioteki Spotify. Wymaga darmowego konta.', + 'Spotify Importer v2': 'Importer Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Ten importer wymaga Spotify Client ID i Client Secret. Aby je uzyskać:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Przejdź na developer.spotify.com/dashboard i utwórz aplikację.', + 'Open in Browser': 'Otwórz w przeglądarce', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. Przejdź do ustawień aplikacji, którą właśnie utworzyłeś i ustaw URL przekierowania na: ', + 'Copy the Redirect URL': 'Skopiuj URL przekierowania', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Autoryzuj', + 'Logged in as: ': 'Zalogowano jako: ', + 'Import playlists by URL': 'Importuj playlisty przez adres URL', + 'URL': 'URL', + 'Options': 'Ustawienia', + 'Invalid/Unsupported URL': 'Niepoprawny/nieobsługiwany adres URL', + 'Please wait...': 'Zaczekaj...', + 'Login using email': 'Zaloguj przy użyciu adresu email', + 'Track removed from offline!': 'Utwór usunięty z biblioteki offline!', + 'Removed album from offline!': 'Album usunięty z biblioteki offline!', + 'Playlist removed from offline!': + 'Playlista usunięta z biblioteki offline!', + 'Repeat': 'Powtarzaj', + 'Repeat one': 'Powtarzaj bieżący', + 'Repeat off': 'Nie powtarzaj', + 'Love': 'Dodaj do ulubionych', + 'Unlove': 'Usuń z ulubionych', + 'Dislike': 'Nie lubię tego', + 'Close': 'Zamknij', + 'Sort playlist': 'Sortuj playlistę', + 'Sort ascending': 'Sortuj rosnąco', + 'Sort descending': 'Sortuj malejąco', + 'Stop': 'Zatrzymaj', + 'Start': 'Rozpocznij', + 'Clear all': 'Wyczyść wszystko', + 'Play previous': 'Odtwórz poprzedni', + 'Play': 'Odtwórz', + 'Pause': 'Pauza', + 'Remove': 'Usuń', + 'Seekbar': 'Suwak', + 'Singles': 'Single', + 'Featured': 'Wyróżnione', + 'Fans': 'Fani', + 'Duration': 'Czas trwania', + 'Sort': 'Sortowanie', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Twój ARL mógł wygasnąć, spróbuj wylogować się i zalogować jeszcze raz używając nowego tokena ARL lub przeglądarki.' + }, + 'pt_br': { + 'Home': 'Início', + 'Search': 'Busca', + 'Library': 'Biblioteca', + "Offline mode, can't play flow or smart track lists.": + 'Modo offline, não reproduz Flow ou playlists inteligentes.', + 'Added to library': 'Adicionado à biblioteca', + 'Download': 'Download', + 'Disk': 'Disco', + 'Offline': 'Offline', + 'Top Tracks': 'Top faixas', + 'Show more tracks': 'Mostrar mais faixas', + 'Top': 'Top', + 'Top Albums': 'Álbuns Mais Ouvidos', + 'Show all albums': 'Mostrar todos os álbuns', + 'Discography': 'Discografia', + 'Default': 'Padrão', + 'Reverse': 'Reverter', + 'Alphabetic': 'Alfabético', + 'Artist': 'Artista', + 'Post processing...': 'Pós-processamento...', + 'Done': 'Feito', + 'Delete': 'Deletar', + 'Are you sure you want to delete this download?': + 'Tem certeza de que quer excluir este download?', + 'Cancel': 'Cancelar', + 'Downloads': 'Downloads', + 'Clear queue': 'Limpar fila', + "This won't delete currently downloading item": + 'Isso não irá excluir o item de download atual', + 'Are you sure you want to delete all queued downloads?': + 'Tem certeza de que quer excluir todos os downloads enfileirados?', + 'Clear downloads history': 'Limpar histórico de downloads', + 'WARNING: This will only clear non-offline (external downloads)': + 'AVISO: Isto só limpará os não offline (downloads externos)', + 'Please check your connection and try again later...': + 'Por favor, verifique sua conexão e tente novamente mais tarde...', + 'Show more': 'Mostrar mais', + 'Importer': 'Importador', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Atualmente suportando apenas Spotify, com limite de 100 faixas', + 'Due to API limitations': 'Devido a limitações de API', + 'Enter your playlist link below': 'Insira o link da sua playlist abaixo', + 'Error loading URL!': 'Erro ao carregar URL!', + 'Convert': 'Converter', + 'Download only': 'Somente download', + 'Downloading is currently stopped, click here to resume.': + 'O download está parado no momento, clique aqui para retomar.', + 'Tracks': 'Faixas', + 'Albums': 'Álbuns', + 'Artists': 'Artistas', + 'Playlists': 'Playlists', + 'Import': 'Importar', + 'Import playlists from Spotify': 'Importar playlists do Spotify', + 'Statistics': 'Estatísticas', + 'Offline tracks': 'Faixas offline', + 'Offline albums': 'Álbuns offline', + 'Offline playlists': 'Playlists offline', + 'Offline size': 'Ocupado offline', + 'Free space': 'Espaço livre', + 'Loved tracks': 'Faixas favoritas', + 'Favorites': 'Favoritos', + 'All offline tracks': 'Todas as faixas offline', + 'Create new playlist': 'Criar nova playlist', + 'Cannot create playlists in offline mode': + 'Não é possível criar playlists no modo offline', + 'Error': 'Erro', + 'Error logging in! Please check your token and internet connection and try again.': + 'Erro de login! Por favor, verifique seu token e conexão com a internet e tente novamente.', + 'Dismiss': 'Dispensar', + 'Welcome to': 'Bem-vindo ao', + 'Please login using your Deezer account.': + 'Faça login usando sua conta do Deezer.', + 'Login using browser': 'Login usando o navegador', + 'Login using token': 'Login usando o token', + 'Enter ARL': 'Introduzir ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Salvar', + "If you don't have account, you can register on deezer.com for free.": + 'Se você não tem conta, você pode se registrar no deezer.com de graça.', + 'Open in browser': 'Abrir no navegador', + "By using this app, you don't agree with the Deezer ToS": + 'Ao utilizar este aplicativo, você não concorda com os termos e condições de uso do Deezer', + 'Play next': 'Reproduzir à seguir', + 'Add to queue': 'Adicionar à fila', + 'Add track to favorites': 'Adicionar faixa aos favoritos', + 'Add to playlist': 'Adicionar à playlist', + 'Select playlist': 'Selecionar playlist', + 'Track added to': 'Faixa adicionada a', + 'Remove from playlist': 'Remover da playlist', + 'Track removed from': 'Faixa removida de', + 'Remove favorite': 'Remover favorito', + 'Track removed from library': 'Faixa removida da biblioteca', + 'Go to': 'Ir para', + 'Make offline': 'Tornar offline', + 'Add to library': 'Adicionar à biblioteca', + 'Remove album': 'Remover álbum', + 'Album removed': 'Álbum removido', + 'Remove from favorites': 'Remover dos favoritos', + 'Artist removed from library': 'Artista removido da biblioteca', + 'Add to favorites': 'Adicionar aos favoritos', + 'Remove from library': 'Remover da biblioteca', + 'Add playlist to library': 'Adicionar playlist à biblioteca', + 'Added playlist to library': 'Playlist adicionada à biblioteca', + 'Make playlist offline': 'Tornar playlist offline', + 'Download playlist': 'Baixar playlist', + 'Create playlist': 'Criar playlist', + 'Title': 'Título', + 'Description': 'Descrição', + 'Private': 'Privada', + 'Collaborative': 'Colaborativa', + 'Create': 'Criar', + 'Playlist created!': 'Playlist criada!', + 'Playing from:': 'Reproduzindo de:', + 'Queue': 'Fila de reprodução', + 'Offline search': 'Busca offline', + 'Search Results': 'Resultado de busca', + 'No results!': 'Nenhum resultado encontrado!', + 'Show all tracks': 'Mostrar todas as faixas', + 'Show all playlists': 'Mostrar todas as playlists', + 'Settings': 'Configurações', + 'General': 'Geral', + 'Appearance': 'Aparência', + 'Quality': 'Qualidade', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Atualmente', + 'Select theme': 'Selecionar tema', + 'Dark': 'Escuro', + 'Black (AMOLED)': 'Preto (AMOLED)', + 'Deezer (Dark)': 'Deezer (Escuro)', + 'Primary color': 'Cor primária', + 'Selected color': 'Cor selecionada', + 'Use album art primary color': 'Usar cor primária da arte do álbum', + 'Warning: might be buggy': 'Aviso: pode haver bugs', + 'Mobile streaming': 'Transmissão via Dados Móveis', + 'Wifi streaming': 'Transmissão via WiFi', + 'External downloads': 'Downloads externos', + 'Content language': 'Linguagem do conteúdo', + 'Not app language, used in headers. Now': + 'Não é o idioma do aplicativo, usado nos cabeçalhos. Atualmente', + 'Select language': 'Selecionar o idioma', + 'Content country': 'País do conteúdo', + 'Country used in headers. Now': 'País usado em cabeçalhos. Atualmente', + 'Log tracks': 'Log de faixas', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Envie registro de faixas ouvidas para o Deezer, habilite-o para que recursos como o Flow funcionem corretamente', + 'Offline mode': 'Modo offline', + 'Will be overwritten on start.': 'Será substituído no início.', + 'Error logging in, check your internet connections.': + 'Erro ao fazer login, verifique suas conexões de internet.', + 'Logging in...': 'Fazendo login...', + 'Download path': 'Caminho de download', + 'Downloads naming': 'Nomenclatura de downloads', + 'Downloaded tracks filename': 'Nome do arquivo das faixas baixadas', + 'Valid variables are': 'Variáveis válidas são', + 'Reset': 'Redefinir', + 'Clear': 'Limpar', + 'Create folders for artist': 'Criar pastas para artistas', + 'Create folders for albums': 'Criar pastas para álbuns', + 'Separate albums by discs': 'Separar álbuns por discos', + 'Overwrite already downloaded files': 'Substituir arquivos já baixados', + 'Copy ARL': 'Copiar ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copiar \"userToken/ARL Cookie\" para uso em outros aplicativos.', + 'Copied': 'Copiado', + 'Log out': 'Desconectar', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Devido à incompatibilidade do plug-in, o login usando o navegador fica indisponível sem reiniciar.', + '(ARL ONLY) Continue': '(APENAS ARL) Continuar', + 'Log out & Exit': 'Desconectar e sair', + 'Pick-a-Path': 'Escolha um caminho', + 'Select storage': 'Selecionar armazenamento', + 'Go up': 'Ir para cima', + 'Permission denied': 'Permissão negada', + 'Language': 'Linguagem', + 'Language changed, please restart ReFreezer to apply!': + 'Idioma alterado, reinicie o ReFreezer para aplicar!', + 'Importing...': 'Importando...', + 'Radio': 'Rádio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Faixa não está disponível no Deezer!', + 'Failed to download track! Please restart.': + 'Falha ao baixar a faixa! Reinicie.', + 'Storage permission denied!': 'Permissão de armazenamento negada!', + 'Failed': 'Falha', + 'Queued': 'Na fila', + 'External': 'Armazenamento', + 'Restart failed downloads': 'Reiniciar downloads com falha', + 'Clear failed': 'Limpar downloads com falha', + 'Download Settings': 'Download', + 'Create folder for playlist': 'Criar pastas para playlists', + 'Download .LRC lyrics': 'Baixar letras \".lrc\"', + 'Proxy': 'Proxy', + 'Not set': 'Não configurado', + 'Search or paste URL': 'Buscar ou colar URL', + 'History': 'Histórico', + 'Download threads': 'Downloads simultâneos', + 'Lyrics unavailable, empty or failed to load!': + 'Letra indisponível, vazia ou falhou ao carregar!', + 'About': 'Sobre', + 'Telegram Channel': 'Canal do Telegram', + 'To get latest releases': 'Para obter as versões mais recentes', + 'Official chat': 'Bate-papo oficial', + 'Telegram Group': 'Grupo do Telegram', + 'Huge thanks to all the contributors! <3': + 'Muito obrigado a todos os contribuidores! ❤️', + 'Edit playlist': 'Editar playlist', + 'Update': 'Atualizar', + 'Playlist updated!': 'Playlist atualizada!', + 'Downloads added!': 'Downloads adicionados!', + 'Save cover file for every track': + 'Salvar arquivo de capa para todas as faixas', + 'Download Log': 'Log de download', + 'Repository': 'Repositório', + 'Source code, report issues there.': 'Código-fonte, relate problemas lá.', + 'Use system theme': 'Usar tema do sistema', + 'Light': 'Claro', + 'Popularity': 'Popularidade', + 'User': 'Usuário', + 'Track count': 'Contagem de faixa', + "If you want to use custom directory naming - use '/' as directory separator.": + "Se você quiser usar uma nomenclatura de diretório personalizada - use '/' como separador de diretório.", + 'Share': 'Compartilhar', + 'Save album cover': 'Salvar capa do álbum', + 'Warning': 'Atenção', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Usar muitos downloads simultâneos em dispositivos mais antigos ou mais fracos pode causar travamentos!', + 'Create .nomedia files': 'Criar arquivos \".nomedia\"', + 'To prevent gallery being filled with album art': + 'Para evitar que a galeria fique cheia de arte do álbum', + 'Sleep timer': 'Temporizador de sono', + 'Minutes:': 'Minutos:', + 'Hours:': 'Horas:', + 'Cancel current timer': 'Cancelar cronômetro atual', + 'Current timer ends at': 'Cronômetro atual termina às', + 'Smart track list': 'Playlist inteligente', + 'Shuffle': 'Aleatório', + 'Library shuffle': 'Biblioteca aleatória', + 'Ignore interruptions': 'Ignorar interrupções', + 'Requires app restart to apply!': + 'Requer reinicialização do aplicativo para aplicar!', + 'Ask before downloading': 'Pergunte antes de baixar', + 'Search history': 'Histórico de busca', + 'Clear search history': 'Limpar histórico de busca', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Faça login para ativar o scrobbling.', + 'Login to LastFM': 'Login no LastFM', + 'Username': 'Nome do usuário', + 'Password': 'Senha', + 'Login': 'Conectar', + 'Authorization error!': 'Erro de autorização!', + 'Logged out!': 'Desconectado!', + 'Lyrics': 'Letra', + 'Player gradient background': 'Fundo gradiente no player', + 'Updates': 'Atualizações', + 'You are running latest version!': 'Você está executando a última versão!', + 'New update available!': 'Nova atualização disponível!', + 'Current version: ': 'Versão atual: ', + 'Unsupported platform!': 'Plataforma não suportada!', + 'Freezer Updates': 'Atualizações do Freezer', + 'Update to latest version in the settings.': + 'Atualize para a versão mais recente nas configurações.', + 'Release date': 'Data de lançamento', + 'Shows': 'Shows', + 'Charts': 'Listas', + 'Browse': 'Navegar', + 'Quick access': 'Acesso rápido', + 'Play mix': 'Reproduzir mix', + 'Share show': 'Compartilhar show', + 'Date added': 'Data de adição', + 'Discord': 'Discord', + 'Official Discord server': 'Servidor oficial do Discord', + 'Restart of app is required to properly log out!': + 'É necessário reiniciar o aplicativo para desconectar corretamente!', + 'Artist separator': 'Separador de artista', + 'Singleton naming': 'Nomenclatura de download único', + 'Keep the screen on': 'Manter a tela ativada', + 'Wakelock enabled!': 'Wakelock habilitado!', + 'Wakelock disabled!': 'Wakelock desativado!', + 'Show all shows': 'Mostrar todos os shows', + 'Episodes': 'Episódios', + 'Show all episodes': 'Mostrar todos os episódios', + 'Album cover resolution': 'Resolução da capa do álbum', + "WARNING: Resolutions above 1200 aren't officially supported": + 'AVISO: Resoluções acima de 1200 não são oficialmente suportadas', + 'Album removed from library!': 'Álbum removido da biblioteca!', + 'Remove offline': 'Remover offline', + 'Playlist removed from library!': 'Playlist removida da biblioteca!', + 'Blur player background': 'Desfocar fundo do player', + 'Might have impact on performance': 'Pode ter impacto no desempenho', + 'Font': 'Fonte', + 'Select font': 'Selecionar fonte', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Este app não é feito para suportar muitas fontes, ele pode quebrar layouts e transbordar. Use por sua conta e risco!', + 'Enable equalizer': 'Ativar equalizador', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Pode ativar alguns apps de equalização para funcionar. Requer reinicialização do Freezer', + 'Visualizer': 'Visualizador', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Mostrar visualizador na página da letra. AVISO: Requer permissão de microfone!', + 'Tags': 'Tags', + 'Album': 'Álbum', + 'Track number': 'Número da faixa', + 'Disc number': 'Número do disco', + 'Album artist': 'Artista do álbum', + 'Date/Year': 'Data/Ano', + 'Label': 'Gravadora', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Total de faixa', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Letra não sincronizada', + 'Genre': 'Gênero', + 'Contributors': 'Colaboradores', + 'Album art': 'Arte do álbum', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'O Deezer está indisponível no seu país, o ReFreezer pode não funcionar corretamente. Por favor, utilize uma VPN', + 'Deezer is unavailable': 'Deezer está indisponível', + 'Continue': 'Continuar', + 'Email Login': 'Login com e-mail', + 'Email': 'E-mail', + 'Missing email or password!': 'E-mail ou senha faltando!', + 'Error logging in using email, please check your credentials.\nError:': + 'Erro ao entrar usando e-mail. Por favor, verifique suas credenciais.\nErro:', + 'Error logging in!': 'Erro no login!', + 'Change display mode': 'Mudar modo de exibição', + 'Enable high refresh rates': 'Habilitar altas taxas de atualização', + 'Display mode': 'Modo de exibição', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importe listas de reprodução do Spotify de até 100 faixas sem login.', + 'Download imported tracks': 'Baixar faixas importadas', + 'Start import': 'Iniciar importação', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Importe qualquer playlist do Spotify, importe da própria biblioteca do Spotify. Requer uma conta gratuita.', + 'Spotify Importer v2': 'Importador do Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Este importador requer o Spotify Client ID e Client Secret. Para obtê-los:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Acesse: developer.spotify.com/dashboard e crie um aplicativo.', + 'Open in Browser': 'Abrir no navegador', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. No aplicativo que você acabou de criar vá para configurações e defina a URL de redirecionamento para: ', + 'Copy the Redirect URL': 'Copiar URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Autorizar', + 'Logged in as: ': 'Conectado como: ', + 'Import playlists by URL': 'Importar playlists pelo URL', + 'URL': 'URL', + 'Options': 'Opções', + 'Invalid/Unsupported URL': 'URL inválido/não suportado', + 'Please wait...': 'Por favor, aguarde...', + 'Login using email': 'Entrar usando e-mail', + 'Track removed from offline!': 'Faixa removida do offline!', + 'Removed album from offline!': 'Álbum removido do offline!', + 'Playlist removed from offline!': 'Playlist removida do offline!', + 'Repeat': 'Repetir', + 'Repeat one': 'Repetir uma', + 'Repeat off': 'Não repetir', + 'Love': 'Curtir', + 'Unlove': 'Não recomendar', + 'Dislike': 'Descurtir', + 'Close': 'Fechar', + 'Sort playlist': 'Classificar playlist', + 'Sort ascending': 'Ordenar ascendente', + 'Sort descending': 'Ordenar decrescente', + 'Stop': 'Parar', + 'Start': 'Começar', + 'Clear all': 'Limpar tudo', + 'Play previous': 'Reproduzir anterior', + 'Play': 'Reproduzir', + 'Pause': 'Pausar', + 'Remove': 'Remover', + 'Seekbar': 'Barra de Busca', + 'Singles': 'Singles', + 'Featured': 'Destaques', + 'Fans': 'Fãs', + 'Duration': 'Duração', + 'Sort': 'Ordenar', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Sua ARL pode estar expirada, tente descontar e fazer login novamente usando uma nova ARL ou via navegador.' + }, + 'ro_ro': { + 'Home': 'Pagina de pornire', + 'Search': 'Căutare', + 'Library': 'Librărie', + "Offline mode, can't play flow or smart track lists.": + 'Mod offline, nu pot reda flow-uri sau liste smart track.', + 'Added to library': 'Adăugat la librărie', + 'Download': 'Descărcați', + 'Disk': 'Disc', + 'Offline': 'Offline', + 'Top Tracks': 'Piese Top', + 'Show more tracks': 'Afișează mai multe piese', + 'Top': 'Top', + 'Top Albums': 'Albume Top', + 'Show all albums': 'Afișează toate albumele', + 'Discography': 'Discografie', + 'Default': 'Implicit', + 'Reverse': 'Invers', + 'Alphabetic': 'Alfabetic', + 'Artist': 'Artist', + 'Post processing...': 'Post procesare...', + 'Done': 'Gata', + 'Delete': 'Ștergeți', + 'Are you sure you want to delete this download?': + 'Ești sigur că vrei să ștergi această descărcare?', + 'Cancel': 'Anulează', + 'Downloads': 'Descărcări', + 'Clear queue': 'Ștergeți coada', + "This won't delete currently downloading item": + 'Aceasta nu va șterge elementul care se descarcă acum', + 'Are you sure you want to delete all queued downloads?': + 'Ești sigur că vrei să ștergi toate descărcările aflate în coadă?', + 'Clear downloads history': 'Șterge istoricul descărcărilor', + 'WARNING: This will only clear non-offline (external downloads)': + 'AVERTISMENT: Aceasta va șterge numai non-offline-urile (descărcări externe)', + 'Please check your connection and try again later...': + 'Vă rugăm să verificați conexiunea și să încercați din nou mai târziu...', + 'Show more': 'Arată mai multe', + 'Importer': 'Importator', + 'Currently supporting only Spotify, with 100 tracks limit': + 'În prezent acceptă doar Spotify, cu limita de 100 de piese', + 'Due to API limitations': 'Din cauza limitărilor API', + 'Enter your playlist link below': + 'Introduceți linkul playlistului de mai jos', + 'Error loading URL!': 'Eroare la încărcarea URL-ului!', + 'Convert': 'Convertiți', + 'Download only': 'Doar descărcare', + 'Downloading is currently stopped, click here to resume.': + 'Descărcarea acum este oprită, faceți clic pentru a relua.', + 'Tracks': 'Piese', + 'Albums': 'Albume', + 'Artists': 'Artiști', + 'Playlists': 'Playlist-uri', + 'Import': 'Importă', + 'Import playlists from Spotify': 'Importă playlist-uri din Spotify', + 'Statistics': 'Statistici', + 'Offline tracks': 'Piese offline', + 'Offline albums': 'Albume offline', + 'Offline playlists': 'Playlist-uri offline', + 'Offline size': 'Dimensiune offline', + 'Free space': 'Spațiu liber', + 'Loved tracks': 'Piese favorite', + 'Favorites': 'Favorite', + 'All offline tracks': 'Toate piesele offline', + 'Create new playlist': 'Crează un nou playlist', + 'Cannot create playlists in offline mode': + 'Nu se pot crea playlist-uri în modul offline', + 'Error': 'Eroare', + 'Error logging in! Please check your token and internet connection and try again.': + 'Eroare la conectare! Verificați token-ul și conexiunea la internet și încercați din nou.', + 'Dismiss': 'Renunță', + 'Welcome to': 'Bun venit la', + 'Please login using your Deezer account.': + 'Te rugăm să te conectezi utilizând contul tau Deezer.', + 'Login using browser': 'Autentificare utilizând browserul', + 'Login using token': 'Autentificare folosind token-ul', + 'Enter ARL': 'Introduceți ARL-ul', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Salvează', + "If you don't have account, you can register on deezer.com for free.": + 'Dacă nu ai un cont, te poți înregistra gratuit pe deezer.com.', + 'Open in browser': 'Deschide în browser', + "By using this app, you don't agree with the Deezer ToS": + 'Prin utilizarea acestei aplicații, nu sunteți de acord cu Deezer ToS', + 'Play next': 'Redă urmatorul', + 'Add to queue': 'Adaugă la coadă', + 'Add track to favorites': 'Adaugă piesa la favorite', + 'Add to playlist': 'Adaugă la un playlist', + 'Select playlist': 'Selectează playlist-ul', + 'Track added to': 'Piesa a fost adăugată la', + 'Remove from playlist': 'Șterge din playlist', + 'Track removed from': 'Piesa a fost eliminată din', + 'Remove favorite': 'Ștergeți favoritul', + 'Track removed from library': 'Piesa a fost eliminată din librărie', + 'Go to': 'Accesați', + 'Make offline': 'Pune offline', + 'Add to library': 'Adaugă la librărie', + 'Remove album': 'Șterge album-ul', + 'Album removed': 'Album-ul a fost șters', + 'Remove from favorites': 'Șterge din favorite', + 'Artist removed from library': 'Artist șters din librărie', + 'Add to favorites': 'Adaugă la favorite', + 'Remove from library': 'Șterge din librărie', + 'Add playlist to library': 'Adaugă playlist-ul la librărie', + 'Added playlist to library': 'Playlist-ul a fost adăugat la librărie', + 'Make playlist offline': 'Pune playlist-ul offline', + 'Download playlist': 'Descarcă playlist-ul', + 'Create playlist': 'Crează un playlist', + 'Title': 'Titlu', + 'Description': 'Descriere', + 'Private': 'Privat', + 'Collaborative': 'Colaborativ', + 'Create': 'Crează', + 'Playlist created!': 'Playlist-ul a fost creat!', + 'Playing from:': 'Redare din:', + 'Queue': 'Coadă', + 'Offline search': 'Căutare offline', + 'Search Results': 'Rezultate găsite', + 'No results!': 'Nici un rezultat', + 'Show all tracks': 'Afișează toate piesele', + 'Show all playlists': 'Afișează toate playlist-urile', + 'Settings': 'Setări', + 'General': 'General', + 'Appearance': 'Aspect', + 'Quality': 'Calitate', + 'Deezer': 'Deezer', + 'Theme': 'Temă', + 'Currently': 'Acum', + 'Select theme': 'Alege tema', + 'Dark': 'Întunecat', + 'Black (AMOLED)': 'Negru (AMOLED)', + 'Deezer (Dark)': 'Deezer (Întunecat)', + 'Primary color': 'Culoare primară', + 'Selected color': 'Culoarea selectată', + 'Use album art primary color': 'Utilizați culoarea primară ale album-ului', + 'Warning: might be buggy': 'Avertisment: ar putea fi cam bug-uit', + 'Mobile streaming': 'Streaming mobil', + 'Wifi streaming': 'Streaming Wi-Fi', + 'External downloads': 'Descărcări externe', + 'Content language': 'Limbajul conținutului', + 'Not app language, used in headers. Now': + 'Nu este limba aplicației, folosit în header (titlu). Acum', + 'Select language': 'Alege o limbă', + 'Content country': 'Conținutul tării', + 'Country used in headers. Now': + 'Țara este utilizată în header-i (titluri). Acum', + 'Log tracks': 'Log-ul pieselor', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Trimiteți log-urile de ascultare a pieselor către Deezer, activați-l pentru funcții precum Flow să funcționeze corect', + 'Offline mode': 'Mod offline', + 'Will be overwritten on start.': 'Va fi suprascris la început.', + 'Error logging in, check your internet connections.': + 'Eroare la conectare, verificați conexiunile la internet.', + 'Logging in...': 'Conectare...', + 'Download path': 'Calea descărcărilor', + 'Downloads naming': 'Denumirea descărcărilor', + 'Downloaded tracks filename': 'Numele pieselor descărcate', + 'Valid variables are': 'Variabilele valide sunt', + 'Reset': 'Resetează', + 'Clear': 'Șterge', + 'Create folders for artist': 'Creați foldere pentru artiști', + 'Create folders for albums': 'Creați foldere pentru albume', + 'Separate albums by discs': 'Separează albumele după discuri', + 'Overwrite already downloaded files': + 'Suprascrieți fișierele deja descărcate', + 'Copy ARL': 'Copiază ARL-ul', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copiază userToken-ul/ARL-ul Cookie utilizarea în alte aplicații.', + 'Copied': 'Copiat', + 'Log out': 'Deconectază', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Din cauza incompatibilității plugin-ului, conectarea utilizând browserul nu este disponibilă fără un restart', + '(ARL ONLY) Continue': '(DOAR ARL) Continuă', + 'Log out & Exit': 'Deconectează și ieși', + 'Pick-a-Path': 'Alege o cale', + 'Select storage': 'Selectează stocarea', + 'Go up': 'Du-te sus', + 'Permission denied': 'Permisie refuzată', + 'Language': 'Limbă', + 'Language changed, please restart ReFreezer to apply!': + 'Limba a fost schimbată, restart-ați ReFreezer pentru a aplica schimbarea!', + 'Importing...': 'Importând...', + 'Radio': 'Radio', + 'Flow': 'Fluxuri', + 'Track is not available on Deezer!': 'Piesa nu este disponibilă pe Deezer!', + 'Failed to download track! Please restart.': + 'Descărcarea piesei nu a reușit! Restart-ați.', + 'Storage permission denied!': 'Permisia de stocare a fost refuzată!', + 'Failed': 'Eșuat', + 'Queued': 'În coadă', + 'External': 'Stocare', + 'Restart failed downloads': 'Restart-ați descărcările eșuate', + 'Clear failed': 'Șterge eșuatele', + 'Download Settings': 'Descărcați setările', + 'Create folder for playlist': 'Creați foldere pentru playlist-uri', + 'Download .LRC lyrics': 'Descărcați versurile .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Nu este setat', + 'Search or paste URL': 'Caută sau pune un URL', + 'History': 'Istorie', + 'Download threads': 'Descărcări simultane', + 'Lyrics unavailable, empty or failed to load!': + 'Versurile nu sunt disponibile, goale sau au eșuat încărcarea!', + 'About': 'Despre', + 'Telegram Channel': 'Canalul Telegram', + 'To get latest releases': 'Pentru a obține cele mai recente versiuni', + 'Official chat': 'Chat-ul oficial', + 'Telegram Group': 'Grupul Telegram', + 'Huge thanks to all the contributors! <3': + 'Mulțumesc frumos tuturor colaboratorilor! <3', + 'Edit playlist': 'Editați playlist-ul', + 'Update': 'Actualizează', + 'Playlist updated!': 'Playlist actualizat!', + 'Downloads added!': 'Descărcări adăugate!', + 'Save cover file for every track': 'Salvează cover-ul pentru fiecare piesă', + 'Download Log': 'Log-ul descărcării', + 'Repository': 'Depozit', + 'Source code, report issues there.': + 'Codul sursă (Source code), raportați problemele acolo.', + 'Use system theme': 'Folosește tema de sistem', + 'Light': 'Luminaosă', + 'Popularity': 'Popularitate', + 'User': 'Utilizator', + 'Track count': 'Număr de melodii', + "If you want to use custom directory naming - use '/' as directory separator.": + "Dacă doriţi să utilizaţi denumirea de director personalizată - utilizaţi '/' ca separator de director.", + 'Share': 'Distribuiți', + 'Save album cover': 'Salvează coperta de album', + 'Warning': 'Avertisment', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Utilizarea de prea multe descărcări simultane pe dispozitive mai vechi/mai slabe ar putea provoca blocarea!', + 'Create .nomedia files': 'Crează fișiere .nomedia', + 'To prevent gallery being filled with album art': + 'Pentru a preveni umplerea galeriei cu coperți de album', + 'Sleep timer': 'Temporizator', + 'Minutes:': 'Minute:', + 'Hours:': 'Ore:', + 'Cancel current timer': 'Anulează cronometrul curent', + 'Current timer ends at': 'Cronometrul curent se termină în', + 'Smart track list': 'Listă de piese inteligentă', + 'Shuffle': 'Mixează', + 'Library shuffle': 'Amestecare librărie', + 'Ignore interruptions': 'Ignoră întreruperile', + 'Requires app restart to apply!': + 'Trebuie să reporniți aplicația pentru a aplica!', + 'Ask before downloading': 'Întreabă înainte de descărcare', + 'Search history': 'Istoric căutare', + 'Clear search history': 'Ștergere istoric căutare', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Autentifică-te pentru a activa scrobbling.', + 'Login to LastFM': 'Conectează-te cu LastFM', + 'Username': 'Nume', + 'Password': 'Parola', + 'Login': 'Autentificare', + 'Authorization error!': 'Eroare la autorizare!', + 'Logged out!': 'Deconectat!', + 'Lyrics': '\"Versuri\"', + 'Player gradient background': 'Gradient de fundal', + 'Updates': 'Actualizări', + 'You are running latest version!': 'Aveţi cea mai recentă versiune!', + 'New update available!': 'O nouă actualizare disponibilă!', + 'Current version: ': 'Versiunea curentă: ', + 'Unsupported platform!': 'Platformă neacceptată!', + 'Freezer Updates': 'Actualizări Freezer', + 'Update to latest version in the settings.': + 'Actualizează la cea mai recentă versiune din setări.', + 'Release date': 'Data lansării', + 'Shows': 'Emisiuni', + 'Charts': 'Topuri', + 'Browse': 'Caută', + 'Quick access': 'Acces rapid', + 'Play mix': 'Redare mix', + 'Share show': 'Distribuie emisiunea', + 'Date added': 'Data adăugării', + 'Discord': 'Discord', + 'Official Discord server': 'Server oficial de Discord', + 'Restart of app is required to properly log out!': + 'Este necesară repornirea aplicației pentru a te deconecta corespunzător!', + 'Artist separator': 'Separator de artiști', + 'Singleton naming': 'Format singular de denumire', + 'Keep the screen on': 'Păstrați ecranul pornit', + 'Wakelock enabled!': 'Wakelock activat!', + 'Wakelock disabled!': 'Wakelock dezactivat!', + 'Show all shows': 'Arată toate albumele', + 'Episodes': 'Episoade', + 'Show all episodes': 'Arată toate episoadele', + 'Album cover resolution': 'Rezoluția copertei de album', + "WARNING: Resolutions above 1200 aren't officially supported": + 'ATENŢIE: Rezoluţiile de peste 1200 nu sunt acceptate oficial', + 'Album removed from library!': 'Album eliminat din bibliotecă!', + 'Remove offline': 'Elimină regim offline', + 'Playlist removed from library!': 'Playlist eliminat din bibliotecă!', + 'Blur player background': 'Încețoșează fundalul player-ului', + 'Might have impact on performance': + 'Ar putea să afecteze performanța aplicației', + 'Font': 'Fontul', + 'Select font': 'Selectează fontul', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Această aplicație nu a fost creată să suporte multe fonturi, s-ar putea să iasă din limitele meniului. Utilizați pe propriul risc!', + 'Enable equalizer': 'Activează egalizatorul', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Porniți niște aplicații de egalizare ca să funcționeze. Necesită repornirea aplicației', + 'Visualizer': 'Vizualizator', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Arată vizuale pe pagina de versuri. ATENȚIE: Necesită permisiunea de acces la microfon!', + 'Tags': 'Etichete', + 'Album': 'Albumul', + 'Track number': 'Numărul piesei', + 'Disc number': 'Numărul discului', + 'Album artist': 'Artistul albumului', + 'Date/Year': 'Data/Anul', + 'Label': 'Casa de discuri', + 'ISRC': 'ISRC', + 'UPC': 'Cod UPC', + 'Track total': 'Numărul total de piese', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Versuri Nesincronizate', + 'Genre': 'Genul', + 'Contributors': 'Contribuitori', + 'Album art': 'Coperta albumului', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer nu este disponibil în țara voastră, e posibil ca ReFreezer să nu funcționeze corect. Vă rugăm să utilizați un VPN', + 'Deezer is unavailable': 'Deezer nu este disponibil', + 'Continue': 'Continuă', + 'Email Login': 'Autentificare prin email', + 'Email': 'Email', + 'Missing email or password!': 'Lipsește adresa de email sau parola!', + 'Error logging in using email, please check your credentials.\nError:': + 'Eroare la autentificare cu adresa email, verificați datele introduse.\nEroarea:', + 'Error logging in!': 'Eroare la autentificare!', + 'Change display mode': 'Schimbați modul de afișare', + 'Enable high refresh rates': 'Activați regimul ridicat de împrospătare', + 'Display mode': 'Mod de afișare', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importă playlist-urile Spotify până la 100 de piese fără autentificare.', + 'Download imported tracks': 'Descarcă piesele importate', + 'Start import': 'Pornește importarea', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Importă orice playlist Spotify, importă din librăria proprie Spotify. Necesită un cont gratuit.', + 'Spotify Importer v2': 'Importator Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Acest importator are nevoie de ID-ul Clientului Spotify și Codul Secret al Clientului. Pentru a le obține:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Mergeți la: developer.spotify.com/dashboard și creați o aplicație.', + 'Open in Browser': 'Deschide în Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. În aplicația pe care tocmai ați creat, mergeți la setări și setați URL-ul de redirecționare la: ', + 'Copy the Redirect URL': 'Copiați URL-ul de redirecționare', + 'Client ID': 'ID-ul Clientului', + 'Client Secret': 'Codul Secret al Clientului', + 'Authorize': 'Autorizează', + 'Logged in as: ': 'Autentificat ca: ', + 'Import playlists by URL': 'Importă playlist-uri prin URL', + 'URL': 'URL', + 'Options': 'Opţiuni', + 'Invalid/Unsupported URL': 'URL Invalid/Neacceptat', + 'Please wait...': 'Așteptați vă rog...', + 'Login using email': 'Autentificare prin email', + 'Track removed from offline!': + 'Piesa a fost eliminată din librăria offline!', + 'Removed album from offline!': 'Album eliminat din librăria offline!', + 'Playlist removed from offline!': 'Playlist eliminat din librăria offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'ru_ru': { + 'Home': 'Главная', + 'Search': 'Поиск', + 'Library': 'Избранное', + "Offline mode, can't play flow or smart track lists.": + 'Автономный режим, невозможно воспроизвести персональные подборки.', + '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': 'Можно импортировать не более 100 треков за раз', + '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': 'Импортировать плейлисты из 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', + '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': 'Выберите тему', + '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': + 'Отправлять статистику прослушивания. Необходимо для правильной работы рекомендаций', + '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.': + 'Может быть полезно для использования в других приложениях. Не сообщайте токен никому!', + '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 ReFreezer to apply!': + 'Язык изменен, перезапустите приложение ReFreezer для применения!', + 'Importing...': 'Импортирование...', + 'Radio': 'Радио', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Трек недоступен в Deezer!', + 'Failed to download track! Please restart.': + 'Не удалось загрузить трек! Пожалуйста, перезапустите.', + 'Storage permission denied!': 'Доступ к хранилищу запрещен!', + 'Failed': 'Ошибка', + 'Queued': 'Добавлено в очередь', + 'External': 'Хранилище', + 'Restart failed downloads': 'Перезапустить загрузки с ошибками', + 'Clear failed': 'Не удалось очистить', + 'Download Settings': 'Настройки загрузок', + 'Create folder for playlist': 'Создавать папки для плейлистов', + 'Download .LRC lyrics': 'Скачивать тексты .LRC', + 'Proxy': 'Настройки прокси', + 'Not set': 'Прокси не настроен', + 'Search or paste URL': 'Введите запрос или ссылку', + 'History': 'История', + 'Download threads': 'Количество одновременных загрузок', + 'Lyrics unavailable, empty or failed to load!': 'Ошибка получения текста!', + 'About': 'О приложении', + 'Telegram Channel': 'Канал в Telegram', + 'To get latest releases': 'Здесь можно скачать официальные обновления', + 'Official chat': 'Официальный чат', + 'Telegram Group': 'Группа в Telegram', + 'Huge thanks to all the contributors! <3': + 'Большое спасибо всем участникам <3', + 'Edit playlist': 'Изменить плейлист', + 'Update': 'Обновить', + 'Playlist updated!': 'Плейлист обновлен!', + 'Downloads added!': 'Добавлено в загрузки!', + 'Save cover file for every track': + 'Обложки для каждого трека отдельным файлом', + 'Download Log': 'Лог загрузок (технические данные)', + 'Repository': 'Репозиторий', + 'Source code, report issues there.': 'Исходный код, вопросы, предложения.', + 'Use system theme': 'Использовать тему системы', + 'Light': 'Светлая', + 'Popularity': 'По популярности', + 'User': 'Сначала мои', + 'Track count': 'Кол-во треков', + "If you want to use custom directory naming - use '/' as directory separator.": + "Используйте '/' для разделения названия папок, если хотите создать собственную иерархию.", + 'Share': 'Поделиться', + 'Save album cover': 'Скачивать обложку альбома', + 'Warning': 'Внимание', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Слишком много параллельных загрузок на слабых устройствах могут привести к сбоям!', + 'Create .nomedia files': 'Создавать файлы .nomedia', + 'To prevent gallery being filled with album art': + 'Чтобы обложки не отображались в галерее', + 'Sleep timer': 'Таймер сна', + 'Minutes:': 'Минут(ы):', + 'Hours:': 'Час(ы):', + 'Cancel current timer': 'Сбросить таймер', + 'Current timer ends at': 'Время сна', + 'Smart track list': 'Умный плейлист', + 'Shuffle': 'Перемешать', + 'Library shuffle': 'Мои треки вперемешку', + 'Ignore interruptions': 'Не вставать на паузу', + 'Requires app restart to apply!': + 'Продолжать воспроизведение при получении звонков/уведомлений', + 'Ask before downloading': 'Спрашивать перед загрузкой', + 'Search history': 'История поиска', + 'Clear search history': 'Очистить историю поиска', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Войдите, чтобы включить скробблинг.', + 'Login to LastFM': 'Авторизоваться через LastFM', + 'Username': 'Имя пользователя', + 'Password': 'Пароль', + 'Login': 'Вход', + 'Authorization error!': 'Ошибка авторизации!', + 'Logged out!': 'Вы успешно вышли!', + 'Lyrics': 'Текст песни', + 'Player gradient background': 'Градиентный фон плеера', + 'Updates': 'Обновления', + 'You are running latest version!': 'Вы используете последнюю версию!', + 'New update available!': 'Доступно обновление!', + 'Current version: ': 'Текущая версия: ', + 'Unsupported platform!': 'Неподдерживаемая платформа!', + 'Freezer Updates': 'Обновления Freezer', + 'Update to latest version in the settings.': + 'Обновитесь до последней версии в настройках.', + 'Release date': 'Дата выпуска', + 'Shows': 'Подкасты', + 'Charts': 'Хит-парады', + 'Browse': 'Обзор', + 'Quick access': 'Быстрый доступ', + 'Play mix': 'Воспроизвести микс', + 'Share show': 'Поделиться подкастом', + 'Date added': 'Дата добавления', + 'Discord': 'Discord', + 'Official Discord server': 'Официальный Discord сервер', + 'Restart of app is required to properly log out!': + 'Необходим перезапуск приложения для корректного выхода!', + 'Artist separator': 'Разделитель нескольких исполнителей', + 'Singleton naming': 'Шаблон для названия синглов', + 'Keep the screen on': 'Не выключать экран', + 'Wakelock enabled!': 'Экран не будет выключен', + 'Wakelock disabled!': 'Экран будет выключен', + 'Show all shows': 'Показать все подкасты', + 'Episodes': 'Эпизоды', + 'Show all episodes': 'Показать все эпизоды', + 'Album cover resolution': 'Разрешение обложки', + "WARNING: Resolutions above 1200 aren't officially supported": + 'ВНИМАНИЕ: Разрешения выше 1200 официально не поддерживаются', + 'Album removed from library!': 'Альбом удален из избранного!', + 'Remove offline': 'Отключить автономный режим', + 'Playlist removed from library!': 'Плейлист удален из избранного!', + 'Blur player background': 'Размыть фон плеера', + 'Might have impact on performance': 'Может повлиять на производительность', + 'Font': 'Шрифт', + 'Select font': 'Выбрать шрифт', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Осторожно, использование внешних шрифотов может сломать разметку и вненший вид приложения!', + 'Enable equalizer': 'Поддержка сторонних эквалайзеров', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Может помочь, если используется несистемный эквалайзер. Требуетсся перезапуск', + 'Visualizer': 'Визуализация', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Отображать визуаализацию на на экране текста. Требуется разрешение \"Доступ к микрофону\"', + 'Tags': 'Какие теги сохранять', + 'Album': 'Альбом', + 'Track number': 'Номер трека', + 'Disc number': 'Номер диска', + 'Album artist': 'Исполнитель', + 'Date/Year': 'Дата/год', + 'Label': 'Издатель', + 'ISRC': 'ISRC-код', + 'UPC': 'UPC-код', + 'Track total': 'Кол-во треков в альбоме', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Текст песни', + 'Genre': 'Жанр', + 'Contributors': 'Участники', + 'Album art': 'Обложка', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'sk_sk': { + 'Home': 'Domov', + 'Search': 'Hľadať', + 'Library': 'Knižnica', + "Offline mode, can't play flow or smart track lists.": + 'Offline mód, nemôžete spustiť flow alebo smart track zoznam.', + 'Added to library': 'Pridané do knižnice', + 'Download': 'Stiahnuť', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Najlepšie skladby', + 'Show more tracks': 'Zobraziť viac skladieb', + 'Top': 'Najlepšie', + 'Top Albums': 'Najlepšie albumy', + 'Show all albums': 'Zobraziť všetky albumy', + 'Discography': 'Diskografia', + 'Default': 'Predvolené', + 'Reverse': 'Opačne', + 'Alphabetic': 'Abecedne', + 'Artist': 'Umelec', + 'Post processing...': 'Prebieha spracovanie...', + 'Done': 'Hotovo', + 'Delete': 'Odstrániť', + 'Are you sure you want to delete this download?': + 'Ste si istý, že chcete odstrániť tieto stiahnuté?', + 'Cancel': 'Zrušiť', + 'Downloads': 'Stiahnuté', + 'Clear queue': 'Vyčistiť poradie', + "This won't delete currently downloading item": + 'Týmto sa neodstráni aktuálne sťahovaná položka', + 'Are you sure you want to delete all queued downloads?': + 'Ste si istý, ťe chcete odstrániť všetky sťahovania v poradí?', + 'Clear downloads history': 'Vyčistiť históriu sťahovania', + 'WARNING: This will only clear non-offline (external downloads)': + 'UPOZORNENIE: Toto vymaže iba súbory, ktoré nie sú offline (stiahnuté)', + 'Please check your connection and try again later...': + 'Skontrolujte vaše internetové pripojenie a skúste neskôr...', + 'Show more': 'Zobraziť viac', + 'Importer': 'Importovať', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Momentálne podporované iba Spotify s limitom 100 skladieb', + 'Due to API limitations': 'Kvôli obmedzeniam API', + 'Enter your playlist link below': + 'Nižšie zadajte odkaz na svoj zoznam skladieb', + 'Error loading URL!': 'Chyba načítania URL!', + 'Convert': 'Konvertovať', + 'Download only': 'Iba na stiahnutie', + 'Downloading is currently stopped, click here to resume.': + 'Sťahovanie je zastavené, kliknite sem pre obnovenie.', + 'Tracks': 'Skladby', + 'Albums': 'Albumy', + 'Artists': 'Umelci', + 'Playlists': 'Playlisty', + 'Import': 'Importovať', + 'Import playlists from Spotify': 'Importovať playlist zo Spotify', + 'Statistics': 'Štatistiky', + 'Offline tracks': 'Offline skladby', + 'Offline albums': 'Offline albumy', + 'Offline playlists': 'Offline playlisty', + 'Offline size': 'Offline veľkosť', + 'Free space': 'Voľné miesto', + 'Loved tracks': 'Obľúbené skladby', + 'Favorites': 'Obľúbené', + 'All offline tracks': 'Všetky offline skladby', + 'Create new playlist': 'Vytvoriť nový playlist', + 'Cannot create playlists in offline mode': + 'Nemožem vytvoriť playlist v offline móde', + 'Error': 'Chyba', + 'Error logging in! Please check your token and internet connection and try again.': + 'Chyba prihlásenia!Skontrolujte váš token, internetové pripojenie a skúste znova.', + 'Dismiss': 'Zavrieť', + 'Welcome to': 'Vitajte v', + 'Please login using your Deezer account.': + 'Prosím prihláste sa s použitím Deezer účtu.', + 'Login using browser': 'Prihlásenie cez prehliadač', + 'Login using token': 'Prihlásenie cez token', + 'Enter ARL': 'Zadajte ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Uložiť', + "If you don't have account, you can register on deezer.com for free.": + 'Ak nemáte účet, možete sa zaregistrovať na deezer.com zadarmo.', + 'Open in browser': 'Otvoriť v prehliadači', + "By using this app, you don't agree with the Deezer ToS": + 'Použitím tejto aplikácie nesúhlasíte s Deezer ToS', + 'Play next': 'Hrať dalej', + 'Add to queue': 'Pridať do poradia', + 'Add track to favorites': 'Pridať skladbu do obľúbených', + 'Add to playlist': 'Pridať do playlistu', + 'Select playlist': 'Vybrať playlist', + 'Track added to': 'Skladba pridaná do', + 'Remove from playlist': 'Odstrániť z playlistu', + 'Track removed from': 'Skladba odstránená z', + 'Remove favorite': 'Odstrániť obľúbené', + 'Track removed from library': 'Skladba odstránená z knižnice', + 'Go to': 'Isť na', + 'Make offline': 'Vytvoriť offline', + 'Add to library': 'Pridať do knižnice', + 'Remove album': 'Odstrániť album', + 'Album removed': 'Album odstránený', + 'Remove from favorites': 'Odstrániť z obľúbených', + 'Artist removed from library': 'Umelec odstránený z knižnice', + 'Add to favorites': 'Pridať do obľúbených', + 'Remove from library': 'Odstrániť z knižnice', + 'Add playlist to library': 'Pridať playlist do knižnice', + 'Added playlist to library': 'Pridaný playlist do knižnice', + 'Make playlist offline': 'Vytvoriť playlist offline', + 'Download playlist': 'Stiahnuť playlist', + 'Create playlist': 'vytvoriť playlist', + 'Title': 'Názov', + 'Description': 'Popis', + 'Private': 'Sukromné', + 'Collaborative': 'Kolaboratívne', + 'Create': 'Vytvoriť', + 'Playlist created!': 'Playlist vytvorený!', + 'Playing from:': 'Hrá z:', + 'Queue': 'Poradie', + 'Offline search': 'Offline hľadanie', + 'Search Results': 'Výsledok hľadania', + 'No results!': 'Bez výsledku!', + 'Show all tracks': 'Zobraziť všetky skladby', + 'Show all playlists': 'Zobraziť všetky playlisty', + 'Settings': 'Nastavenia', + 'General': 'Hlavné', + 'Appearance': 'Vzhľad', + 'Quality': 'Kvalita', + 'Deezer': 'Deezer', + 'Theme': 'Téma', + 'Currently': 'Momentálne', + 'Select theme': 'vybrať tému', + 'Dark': 'Temná', + 'Black (AMOLED)': 'Čierna (AMOLED)', + 'Deezer (Dark)': 'Deezer (Temná)', + 'Primary color': 'Hlavná farba', + 'Selected color': 'Vybraná farba', + 'Use album art primary color': 'Použiť farbu z obrázku albumu', + 'Warning: might be buggy': 'Varovanie! Môže obsahovať chyby', + 'Mobile streaming': 'Mobilné pripojenie', + 'Wifi streaming': 'Wifi pripojenie', + 'External downloads': 'Pri sťahovaní', + 'Content language': 'Jazyk obsahu', + 'Not app language, used in headers. Now': + 'Toto nie je jazyk aplikácie, používa sa v hlavičkách. Teraz', + 'Select language': 'Vybrať jazyk', + 'Content country': 'Krajina obsahu', + 'Country used in headers. Now': 'Krajina použitá v hlavičkách. Teraz', + 'Log tracks': 'Záznam skladieb', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Odosielať históriu do Deezeru aby funkcie ako Flow fungovali správne', + 'Offline mode': 'Offline mód', + 'Will be overwritten on start.': 'Bude prepísané pri štarte.', + 'Error logging in, check your internet connections.': + 'Chyba pri prihlásení, skontrolujte svoje internetové pripojenie.', + 'Logging in...': 'Prihlasujem...', + 'Download path': 'Priečinok pre sťahovanie', + 'Downloads naming': 'Názov stiahnutých', + 'Downloaded tracks filename': 'Názov stiahnutých skladieb', + 'Valid variables are': 'Platné premenné sú', + 'Reset': 'Resetovať', + 'Clear': 'Vyčistiť', + 'Create folders for artist': 'Vytvoriť priečinok pre umelca', + 'Create folders for albums': 'Vytvoriť priečinok pre albumy', + 'Separate albums by discs': 'Oddeliť albumy od diskov', + 'Overwrite already downloaded files': 'Prepísať už stiahnuté súbory', + 'Copy ARL': 'Кopírovať ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Kopírovať userToken/ARL Cookie pre použitie v iných aplikáciách.', + 'Copied': 'Skopírované', + 'Log out': 'Odhlásiť sa', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Z dôvodu nekompatibility doplnkov je prihlásenie pomocou prehliadača bez reštartu nedostupné.', + '(ARL ONLY) Continue': '(IBA ARL) Pokračovať', + 'Log out & Exit': 'Odhlásiť a ukončiť', + 'Pick-a-Path': 'Vybrať cestu', + 'Select storage': 'Vybrať úložisko', + 'Go up': 'Ísť hore', + 'Permission denied': 'Prístup zamietnutý', + 'Language': 'Jazyk', + 'Language changed, please restart ReFreezer to apply!': + 'Jazyk zmenený, pre použitie prosím reštartujte Freezer!', + 'Importing...': 'Importujem...', + 'Radio': 'Rádio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Skladba nie je dostupná v Deezri!', + 'Failed to download track! Please restart.': + 'Sťahovanie skladieb zlyhalo! Prosím reštartujte Freezer.', + 'Storage permission denied!': 'Prístup k úložisku zamietnutý!', + 'Failed': 'Zlyhanie', + 'Queued': 'Poradie', + 'External': 'Úložisko', + 'Restart failed downloads': 'Reštartovať neúspešné sťahovania', + 'Clear failed': 'Vyčistiť zlyhania', + 'Download Settings': 'Nastavenie sťahovania', + 'Create folder for playlist': 'Vytvoriť priečinok pre playlist', + 'Download .LRC lyrics': 'Stiahnuť .LRC texty', + 'Proxy': 'Proxy', + 'Not set': 'Nenastavené', + 'Search or paste URL': 'Hľadať alebo vložiť URL', + 'History': 'História', + 'Download threads': 'Súbežné sťahovanie', + 'Lyrics unavailable, empty or failed to load!': + 'Texty nedostupné, prázdne alebo chyba pri načítaní!', + 'About': 'O aplikácii', + 'Telegram Channel': 'Telegram kanál', + 'To get latest releases': 'Ak chcete získať najnovšie vydania', + 'Official chat': 'Oficiálny chat', + 'Telegram Group': 'Telegram skupina', + 'Huge thanks to all the contributors! <3': + 'Obrovská vďaka všetkým prispievateľom! <3', + 'Edit playlist': 'Upraviť playlist', + 'Update': 'Aktualizácia', + 'Playlist updated!': 'Playlist aktualizovaný!', + 'Downloads added!': 'Pridané sťahovania!', + 'Save cover file for every track': + 'Uložiť obrázok albumu pre každú skladbu', + 'Download Log': 'Protokol sťahovania', + 'Repository': 'Repozitár', + 'Source code, report issues there.': 'Zdrojový kód, tam nahláste problémy.', + 'Use system theme': 'Použiť systémovú tému', + 'Light': 'Svetlá', + 'Popularity': 'Popularita', + 'User': 'Používateľ', + 'Track count': 'Počet skladieb', + "If you want to use custom directory naming - use '/' as directory separator.": + 'Ak chcete použiť vlastné pomenovanie adresárov - použite ako oddeľovač adresárov znak „/“.', + 'Share': 'Zdieľať', + 'Save album cover': 'Uložiť obrázok albumu', + 'Warning': 'Upozornenie', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Použitie príliš veľkého počtu súbežných stiahnutí na starších / slabších zariadeniach môže spôsobiť zlyhanie!', + 'Create .nomedia files': 'Vytvoriť súbory .nomedia', + 'To prevent gallery being filled with album art': + 'Zabráni sa tomu, aby sa galéria naplnila obrázkami albumov', + 'Sleep timer': 'Časovač vypnutia', + 'Minutes:': 'Minúty:', + 'Hours:': 'Hodiny:', + 'Cancel current timer': 'Zrušiť aktuálny časovač', + 'Current timer ends at': 'Aktuálny časovač končí o', + 'Smart track list': 'Smart zoznam skladieb', + 'Shuffle': 'Zamiešať', + 'Library shuffle': 'Zamiešať knižnicu', + 'Ignore interruptions': 'Ignorovať prerušenia', + 'Requires app restart to apply!': 'Vyžaduje sa reštart aplikácie!', + 'Ask before downloading': 'Opýtať sa pred stiahnutím', + 'Search history': 'História hľadania', + 'Clear search history': 'Vyčistiť históriu hľadania', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Prihlásiť sa pre povolenie scrobblingu.', + 'Login to LastFM': 'Prihlásiť do LastFM', + 'Username': 'Meno', + 'Password': 'Heslo', + 'Login': 'Prihlásiť', + 'Authorization error!': 'Chyba autorizácie!', + 'Logged out!': 'Odhlásený!', + 'Lyrics': 'Texty', + 'Player gradient background': 'Prechod pozadia v prehrávači', + 'Updates': 'Aktualizácie', + 'You are running latest version!': 'Používate najnovšiu verziu!', + 'New update available!': 'Je dostupná aktualizácia!', + 'Current version: ': 'Aktuálna verzia: ', + 'Unsupported platform!': 'Nepodporovaná platforma!', + 'Freezer Updates': 'Freezer aktualizácie', + 'Update to latest version in the settings.': + 'Aktualizujte na najnovšiu verziu v nastaveniach.', + 'Release date': 'Dátum vydania', + 'Shows': 'Podcasty', + 'Charts': 'Rebríčky', + 'Browse': 'Prehliadať', + 'Quick access': 'Rýchly prístup', + 'Play mix': 'Hrať mix', + 'Share show': 'Zdieľať podcast', + 'Date added': 'Dátum pridania', + 'Discord': 'Discord', + 'Official Discord server': 'Oficiálny Discord server', + 'Restart of app is required to properly log out!': + 'Na správne odhlásenie je potrebné reštartovať aplikáciu!', + 'Artist separator': 'Oddelenie umelca', + 'Singleton naming': 'Jedinečné pomenovanie', + 'Keep the screen on': 'Nechať obrazovku zapnutú', + 'Wakelock enabled!': 'Wakelock povolený!', + 'Wakelock disabled!': 'Wakelock vypnutý!', + 'Show all shows': 'Zobraziť všetky podcasty', + 'Episodes': 'Časti', + 'Show all episodes': 'Zobraziť všetky časti', + 'Album cover resolution': 'Rozlíšenie obalu albumu', + "WARNING: Resolutions above 1200 aren't officially supported": + 'UPOZORNENIE: Rozlíšenie nad 1 200 nie je oficiálne podporované', + 'Album removed from library!': 'Album odstránený z knižnice!', + 'Remove offline': 'Odstrániť offline', + 'Playlist removed from library!': 'Playlist odstránený z knižnice!', + 'Blur player background': 'Rozostrenie pozadia prehrávača', + 'Might have impact on performance': 'Môže mať vplyv na výkon', + 'Font': 'Písmo', + 'Select font': 'Vybrať písmo', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Táto aplikácia nie je určená na podporu viacerých typov písma, môžu poškodiť rozloženie a pretekať. Používajte na svoje vlastné riziko!', + 'Enable equalizer': 'Zapnúť ekvalizér', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Možnosť fungovania niektorých aplikácií ekvalizéra. Vyžaduje sa reštart aplikácie', + 'Visualizer': 'Vizualizér', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Zobraziť vizualizér na stránke textov. VAROVANIE: Vyžaduje povolenie mikrofónu!', + 'Tags': 'Štítky', + 'Album': 'Album', + 'Track number': 'Číslo skladby', + 'Disc number': 'Číslo disku', + 'Album artist': 'Album umelca', + 'Date/Year': 'Dátum/Rok', + 'Label': 'Menovka', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Skladba celkovo', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Nesynchronizované texty', + 'Genre': 'Žáner', + 'Contributors': 'Prispievatelia', + 'Album art': 'Obrázok albumu', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer nie je dostupný vo vašej krajine a tak, ReFreezer nemusí fungovať správne. Použite prosím VPN', + 'Deezer is unavailable': 'Deezer je nedostupný', + 'Continue': 'Pokračovať', + 'Email Login': 'Prihlásiť cez email', + 'Email': 'Email', + 'Missing email or password!': 'Chýba email alebo heslo!', + 'Error logging in using email, please check your credentials.\nError:': + 'Chyba prihlásenia cez email, prosím skontrolujte vaše zadanie.\nChyba:', + 'Error logging in!': 'Chyba prihlásenia!', + 'Change display mode': 'Zmeniť zobrazenie', + 'Enable high refresh rates': 'Povoliť vysokú obnovovaciu frekvenciu', + 'Display mode': 'Režim zobrazenia', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importovať Spotify playlist do 100 skladieb bez prihlásenia.', + 'Download imported tracks': 'Stiahnuť importované skladby', + 'Start import': 'Spustiť import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Importovať akýkoľvek vlastný Spotify playlist z knižnice. Vyžaduje účet zadarmo.', + 'Spotify Importer v2': 'Spotify importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Importer vyžaduje Spotify Client ID a Client Secret. Na získanie:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + 'Choďte na: developer.spotify.com/dashboard a vytvorte aplikáciu.', + 'Open in Browser': 'Otvoriť v prehliadači', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + 'V aplikácii ktorú ste vytvorili choďte do nastavení a vyberte presmerovať URL na: ', + 'Copy the Redirect URL': 'Kopírovať presmerovanú URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Povoliť', + 'Logged in as: ': 'Prihlásený ako: ', + 'Import playlists by URL': 'Importovať playlist cez URL', + 'URL': 'URL', + 'Options': 'Možnosti', + 'Invalid/Unsupported URL': 'Neplatná/Nepodporovaná URL', + 'Please wait...': 'Prosím, čakajte...', + 'Login using email': 'Prihlásenie cez e-mail', + 'Track removed from offline!': 'Skladba odstránená z knižnice!', + 'Removed album from offline!': 'Album odstránený z knižnice!', + 'Playlist removed from offline!': 'Playlist odstránený z knižnice!', + 'Repeat': 'Opakovať', + 'Repeat one': 'Opakovať jednu', + 'Repeat off': 'Opakovanie vypnuté', + 'Love': 'Obľúbené', + 'Unlove': 'Odstránené z obľúbených', + 'Dislike': 'Nepáči sa mi', + 'Close': 'Zavrieť', + 'Sort playlist': 'Zoradiť playlist', + 'Sort ascending': 'Zoradiť vzostupne', + 'Sort descending': 'Zoradiť zostupne', + 'Stop': 'Stop', + 'Start': 'Spustiť', + 'Clear all': 'Vymazať všetko', + 'Play previous': 'Prehrať predchádzajúcu', + 'Play': 'Prehrať', + 'Pause': 'Pozastaviť', + 'Remove': 'Odstrániť', + 'Seekbar': 'Posuvník', + 'Singles': 'Single', + 'Featured': 'Odporúčané', + 'Fans': 'Fanúšikov', + 'Duration': 'Dĺžka', + 'Sort': 'Zoradiť', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Vaše ARL vypršalo. Skúste sa odhlásiť a znovu prihlásiť s novým ARL alebo cez prehliadač.' + }, + 'sl_SL': { + 'Home': 'Domov', + 'Search': 'Išči', + 'Library': 'Knjižnica', + "Offline mode, can't play flow or smart track lists.": + 'Način brez povezave, ne more predvajati tokov ali pametnih seznamov.', + 'Added to library': 'Dodaj v knjižnico', + 'Download': 'Prenesi', + 'Disk': 'Disk', + 'Offline': 'Brez povezave', + 'Top Tracks': 'Najboljše skladbe', + 'Show more tracks': 'Pokaži več skladb', + 'Top': 'Priljubljeno', + 'Top Albums': 'Priljubljeni albumi', + 'Show all albums': 'Pokaži vse albume', + 'Discography': 'Diskografija', + 'Default': 'Privzeto', + 'Reverse': 'Obratno', + 'Alphabetic': 'Po abecedi', + 'Artist': 'Izvajalec', + 'Post processing...': 'Obdelujem...', + 'Done': 'Opravljeno', + 'Delete': 'Izbriši', + 'Are you sure you want to delete this download?': + 'Ali res želite izbrisati ta prenos?', + 'Cancel': 'Prekini', + 'Downloads': 'Prenosi', + 'Clear queue': 'Počisti čakalno vrsto', + "This won't delete currently downloading item": + 'To vam ne bo pobrisalo elementov ki so trenutno v prenosu', + 'Are you sure you want to delete all queued downloads?': + 'Res želite izbrisati vse vaše prenose v čakalni vrsti?', + 'Clear downloads history': 'Počisti zgodovino prenosov', + 'WARNING: This will only clear non-offline (external downloads)': + 'OPOZORILO: To bo izbrisalo samo zunanje prenose', + 'Please check your connection and try again later...': + 'Preverite svojo povezavo in poskusite ponovno ...', + 'Show more': 'Prikaži več', + 'Importer': 'Orodje za uvoz', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Trenutno je podprt samo Spotify, z največ 100 skladbami', + 'Due to API limitations': 'Zaradi omejitev v API', + 'Enter your playlist link below': + 'Vpiši link do svojega seznama predvajanja', + 'Error loading URL!': 'Napaka pri nalaganju URL-ja!', + 'Convert': 'Pretvori', + 'Download only': 'Samo prejeto', + 'Downloading is currently stopped, click here to resume.': + 'Prenašanje je trenutno ustavljeno, kliknite tukaj za nadaljevanje.', + 'Tracks': 'Skladbe', + 'Albums': 'Albumi', + 'Artists': 'Izvajalci', + 'Playlists': 'Seznami predvajanja', + 'Import': 'Uvozi', + 'Import playlists from Spotify': 'Uvozi sezname predvajanja iz Spotify-a', + 'Statistics': 'Statistike', + 'Offline tracks': 'Posnetki brez povezave', + 'Offline albums': 'Albumi brez povezave', + 'Offline playlists': 'Seznami predvajanja brez povezave', + 'Offline size': 'Velikost brez povezave', + 'Free space': 'Nezaseden prostor', + 'Loved tracks': 'Najbolj priljubljene skladbe', + 'Favorites': 'Priljubljene', + 'All offline tracks': 'Vsi posnetki brez povezave', + 'Create new playlist': 'Nov seznam predvajanja', + 'Cannot create playlists in offline mode': + 'V načinu brez povezave ni mogoče ustvariti seznamov predvajanja', + 'Error': 'Napaka', + 'Error logging in! Please check your token and internet connection and try again.': + 'Napaka pri prijavi! Prosimo preverite svoj žeton in internetno povezavo in poskusite ponovno.', + 'Dismiss': 'Opusti', + 'Welcome to': 'Dobrodošli v', + 'Please login using your Deezer account.': + 'Prosimo prijavite se s svojim Deezer računom.', + 'Login using browser': 'Prijava preko brskalnika', + 'Login using token': 'Prijava z žetonom', + 'Enter ARL': 'Vnesi ARL', + 'Token (ARL)': 'Žeton (ARL)', + 'Save': 'Shrani', + "If you don't have account, you can register on deezer.com for free.": + 'Če še nimate računa, si ga lahko ustvarite na deezer.com zastonj.', + 'Open in browser': 'Odpri v brskalniku', + "By using this app, you don't agree with the Deezer ToS": + 'Z uporabo tega programa boste kršili pogoje uporabe od Dezzer-ja', + 'Play next': 'Predvajaj naslednjega', + 'Add to queue': 'Dodaj v čakalno vrsto', + 'Add track to favorites': 'Dodaj med priljubljene', + 'Add to playlist': 'Dodaj na seznam predvajanja', + 'Select playlist': 'Izberi seznam predvajanja', + 'Track added to': 'Posnetek dodan na', + 'Remove from playlist': 'Odstrani iz seznama predvajanja', + 'Track removed from': 'Odstranjeno iz', + 'Remove favorite': 'Odstrani iz priljubljenih', + 'Track removed from library': 'Odstranjeno iz knjižnice', + 'Go to': 'Pojdi na', + 'Make offline': 'Naredi brez povezave', + 'Add to library': 'Dodaj v knjižnico', + 'Remove album': 'Izbriši album', + 'Album removed': 'Album izbrisan', + 'Remove from favorites': 'Odstrani iz priljubljenih', + 'Artist removed from library': 'Izvajalec izbrisan iz knjižnice', + 'Add to favorites': 'Dodaj med priljubljene', + 'Remove from library': 'Odstrani iz knjižnice', + 'Add playlist to library': 'Dodaj seznam predvajanja v knjižnico', + 'Added playlist to library': 'Dodan seznam predvajanja v knjižnico', + 'Make playlist offline': 'Naredi seznam predvajanja brez povezave', + 'Download playlist': 'Prenesi seznam predvajanja', + 'Create playlist': 'Nov seznam predvajanja', + 'Title': 'Naslov', + 'Description': 'Opis', + 'Private': 'Zasebno', + 'Collaborative': 'Sodelovalno', + 'Create': 'Ustvari', + 'Playlist created!': 'Seznam predvajanja je ustvarjen!', + 'Playing from:': 'Predvajaj iz:', + 'Queue': 'Čakalna vrsta', + 'Offline search': 'Iskanje brez povezave', + 'Search Results': 'Rezultati iskanja', + 'No results!': 'Ni rezultatov!', + 'Show all tracks': 'Prikaži vse psnetke', + 'Show all playlists': 'Pokaži vse liste predvajanja', + 'Settings': 'Nastavitve', + 'General': 'Splošno', + 'Appearance': 'Videz', + 'Quality': 'Kakovost', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Trenutno', + 'Select theme': 'Izberi temo', + 'Dark': 'Temno', + 'Black (AMOLED)': 'Črna (AMOLED)', + 'Deezer (Dark)': 'Deezer (Temno)', + 'Primary color': 'Primarna barva', + 'Selected color': 'Izberi barvo', + 'Use album art primary color': 'Uporabi barvo albuma za primarno barvo', + 'Warning: might be buggy': 'Opozorilo: lahko vsebuje hrošče', + 'Mobile streaming': 'Mobilno pretakanje', + 'Wifi streaming': 'Pretakanje preko Wifi', + 'External downloads': 'Zunanji prenosi', + 'Content language': 'Jezik vsebine', + 'Not app language, used in headers. Now': + 'Ni jezik programa, uporabljeno v glavah. Sedaj', + 'Select language': 'Izberite jezik', + 'Content country': 'Država vsebine', + 'Country used in headers. Now': + 'Država uporabljena v glavah zahtevkov. Sedaj', + 'Log tracks': 'Beleži skladbe', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Pošiljanje zgodovine Deezer-ju, omogoča funkcije, kot so tok, da delujejo pravilno', + 'Offline mode': 'Način brez povezave', + 'Will be overwritten on start.': 'Bo povoženo na začetku.', + 'Error logging in, check your internet connections.': + 'Napaka pri prijavi, preverite svojo povezavo.', + 'Logging in...': 'Prijavljanje...', + 'Download path': 'Pot za prenos', + 'Downloads naming': 'Ime prenosa', + 'Downloaded tracks filename': 'Imena prenesenih posnetkov', + 'Valid variables are': 'Veljavne spremenljivk so', + 'Reset': 'Ponastavi', + 'Clear': 'Počisti', + 'Create folders for artist': 'Ustvari mape za izvajalca', + 'Create folders for albums': 'Ustvari mape za albume', + 'Separate albums by discs': 'Loči albume po diskih', + 'Overwrite already downloaded files': 'Povozi že prenesene datoteke', + 'Copy ARL': 'Kopiraj ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Kopiraj uporabniški žeton/ARL za uporabo v drugih aplikacijah.', + 'Copied': 'Kopirano', + 'Log out': 'Odjava', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Zaradi težav prijava preko brskalnika ni na voljo brez ponovnega zagona.', + '(ARL ONLY) Continue': '(SAMO ARL) Nadaljuj', + 'Log out & Exit': 'Odjava in izhod', + 'Pick-a-Path': 'Izberi poti', + 'Select storage': 'Izberite shrambo', + 'Go up': 'Pojdi gor', + 'Permission denied': 'Dovoljenje zavrnjeno', + 'Language': 'Jezik', + 'Language changed, please restart ReFreezer to apply!': + 'Jezik zamenjan, prosimo ponovno zaženite ReFreezer za uveljavitev!', + 'Importing...': 'Uvažanje...', + 'Radio': 'Radio', + 'Flow': 'Tok', + 'Track is not available on Deezer!': 'Posnetek ni na voljo pri Deezer-ju!', + 'Failed to download track! Please restart.': + 'Prenos gonilnika je spodletel! Prosimo izvedite ponovni zagon.', + 'Storage permission denied!': 'Dovoljenje za shrambo zavrnjeno!', + 'Failed': 'Spodletelo', + 'Queued': 'V čakalni vrsti', + 'External': 'Shramba', + 'Restart failed downloads': 'Ponovno zaženi spodletele prenose', + 'Clear failed': 'Čiščenje neuspešno', + 'Download Settings': 'Nastavitve prenosa', + 'Create folder for playlist': 'Ustvari mapo za seznam predvajanja', + 'Download .LRC lyrics': 'Prenesi .LRC besedilo', + 'Proxy': 'Posredovalni strežniki', + 'Not set': 'Ni nastavljeno', + 'Search or paste URL': 'Iščite ali prilepite URL', + 'History': 'Zgodovina', + 'Download threads': 'Sočasni prenosi', + 'Lyrics unavailable, empty or failed to load!': + 'Besedilo ni na voljo, prazno ali neuspešno naloženo!', + 'About': 'O programu', + 'Telegram Channel': 'Telegram kanal', + 'To get latest releases': 'Da pridobite najnovejšo različico', + 'Official chat': 'Uradni klepet', + 'Telegram Group': 'Telegram skupina', + 'Huge thanks to all the contributors! <3': + 'Velika zahvala vsem sodelavcem! <3', + 'Edit playlist': 'Uredi seznam predvajanja', + 'Update': 'Posodobi', + 'Playlist updated!': 'Seznam predvajanja posodobljen!', + 'Downloads added!': 'Prenos dodan!', + 'Save cover file for every track': 'Shrani sliko albuma za vsak posnetek', + 'Download Log': 'Zapisnik prenosov', + 'Repository': 'Repozitorij', + 'Source code, report issues there.': + 'Izvorna koda, tukaj prijavite napake.', + 'Use system theme': 'Uporabi temo sistema', + 'Light': 'Svetla', + 'Popularity': 'Priljubljenost', + 'User': 'Uporabnik', + 'Track count': 'Število posnetkov', + "If you want to use custom directory naming - use '/' as directory separator.": + "Če želite uporabljati poljubno imenovanje map - uporabite '/' kot ločilo map.", + 'Share': 'Deli', + 'Save album cover': 'Shrani sličico albuma', + 'Warning': 'Opozorilo', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Preveč sočasnih prenosov lahko povzroča težave na starejših/manj zmogljivih napravah!', + 'Create .nomedia files': 'Ustvari datoteke .nomedia', + 'To prevent gallery being filled with album art': + 'Za preprečitev polnjenje galerije s slikami albumov', + 'Sleep timer': 'Časovnik spanja', + 'Minutes:': 'Minut:', + 'Hours:': 'Ur:', + 'Cancel current timer': 'Prekliči trenutni časovnik', + 'Current timer ends at': 'Trenutni časovnik se konča', + 'Smart track list': 'Pametni seznam posnetkov', + 'Shuffle': 'Naključno', + 'Library shuffle': 'Naključno predvajanje knjižnice', + 'Ignore interruptions': 'Ignoriraj prekinitve', + 'Requires app restart to apply!': + 'Uveljavitev zahteva ponovni zagon aplikacije!', + 'Ask before downloading': 'Vprašaj pred prenosom', + 'Search history': 'Zgodovina iskanja', + 'Clear search history': 'Počisti zgodovino iskanja', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Prijava da omogočiš \"scrobbling\".', + 'Login to LastFM': 'Prijava v LastFM', + 'Username': 'Uporabniško ime', + 'Password': 'Geslo', + 'Login': 'Prijava', + 'Authorization error!': 'Napaka overitve!', + 'Logged out!': 'Odjavljen!', + 'Lyrics': 'Besedilo', + 'Player gradient background': 'Predvajalnikov ozadje s prelivom', + 'Updates': 'Posodobitve', + 'You are running latest version!': 'Uporabljate najnovejšo različico!', + 'New update available!': 'Na voljo je nova posodobitev!', + 'Current version: ': 'Trenutna različica: ', + 'Unsupported platform!': 'Nepodprta platforma!', + 'Freezer Updates': 'Freezer posodobitve', + 'Update to latest version in the settings.': + 'Posodobite na najnovejšo verzijo v nastavitvah.', + 'Release date': 'Datum izdaje', + 'Shows': 'Oddaje', + 'Charts': 'Lestvice', + 'Browse': 'Brskanje', + 'Quick access': 'Hitri dostop', + 'Play mix': 'Predvajaj miks', + 'Share show': 'Deli oddajo', + 'Date added': 'Dodano dne', + 'Discord': 'Discord', + 'Official Discord server': 'Uraden Discord strežnik', + 'Restart of app is required to properly log out!': + 'Ponovni zagon aplikacije je potreben za odjavo!', + 'Artist separator': 'Ločilo izvajalcev', + 'Singleton naming': 'Imenovanje \"singletona\"', + 'Keep the screen on': 'Ohrani vklopljen zaslon', + 'Wakelock enabled!': '\"Wakelock\" omogočen!', + 'Wakelock disabled!': '\"Wakelock\" onemogočen!', + 'Show all shows': 'Prikaži vse oddaje', + 'Episodes': 'Epizode', + 'Show all episodes': 'Pokaži vse epizode', + 'Album cover resolution': 'Resolucija slike albuma', + "WARNING: Resolutions above 1200 aren't officially supported": + 'OPOZORILO: Resolucije nad 1200 niso uradno podprte', + 'Album removed from library!': 'Album odstranjen iz knjižnice!', + 'Remove offline': 'Odstrani brezpovezavni', + 'Playlist removed from library!': + 'Seznam predvajanja izbrisan iz knjižnice!', + 'Blur player background': 'Zamegli ozadje predvajalnika', + 'Might have impact on performance': 'Lahko vpliva na odzivnost', + 'Font': 'Pisava', + 'Select font': 'Izberi pisavo', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Ta aplikacija ni namenjena uporabi različnih pisav, saj lahko pokvarijo izgled in obliko besedila. Uporabljaj na lastno odgovornost!', + 'Enable equalizer': 'Omogoči izenačevalnik', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Morda omogoči podporo za nekatere izenačevalniške aplikacije. Zahteva ponovni zagon aplikacije Freezer', + 'Visualizer': 'Vizualizator', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Prikaži zvočni prikazovalnik na strani z besedilom. POZOR: Zahteva dovoljenje za mikrofon!', + 'Tags': 'Oznake', + 'Album': 'Album', + 'Track number': 'Številka posnetka', + 'Disc number': 'Številka diska', + 'Album artist': 'Ustvarjalec albuma', + 'Date/Year': 'Datum/Leto', + 'Label': 'Oznaka', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Skupaj ponetkov', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Nesinhronizirano besedilo', + 'Genre': 'Žanr', + 'Contributors': 'Sodelujoči', + 'Album art': 'Naslovnica albuma', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer ni na voljo v vaši državi, zato ReFreezer mogoče ne bo deloval. Prosimo uporabite VPN', + 'Deezer is unavailable': 'Deezer ni na voljo', + 'Continue': 'Nadaljuj', + 'Email Login': 'E-poštna prijava', + 'Email': 'E-pošta', + 'Missing email or password!': 'Mankajoča e-pošta ali geslo!', + 'Error logging in using email, please check your credentials.\nError:': + 'Napaka pri prijavi z uporabo e-pošte, prosimo preverite poverilnice.\nNapaka:', + 'Error logging in!': 'Napaka pri prijavi!', + 'Change display mode': 'Preklopi način prikaza', + 'Enable high refresh rates': 'Omogoči način hitrega osveževanja', + 'Display mode': 'Način prikaza', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Uvozi Spotify sezname predvajanja do 100 skladb brez prijave.', + 'Download imported tracks': 'Prenesi uvožene skladbe', + 'Start import': 'Začni uvoz', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Uvozi katerikoli seznam predvajanja ali svojo knjižnico iz Spotify. Zahteva brezplačen račun.', + 'Spotify Importer v2': 'Uvoznik Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Ta uvoznih zahteva Spotify ID odjemalca in njegovo skrivnost. Da ju pridobiš:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Pojdi na developer.spotify.com/dashboard in ustvari aplikacijo.', + 'Open in Browser': 'Odpri v brskalniku', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. V aplikaciji, ki je bila pravkar ustvarjena, odpri nastavitve in nastavi preusmeritveni URL na: ', + 'Copy the Redirect URL': 'Kopiraj preusmeritveni URL', + 'Client ID': 'ID odjemalca', + 'Client Secret': 'Skrivnost odjemalca', + 'Authorize': 'Overi', + 'Logged in as: ': 'Prijavljeni kot: ', + 'Import playlists by URL': 'Uvozi seznam predvajanja preko URL povezave', + 'URL': 'URL', + 'Options': 'Možnosti', + 'Invalid/Unsupported URL': 'Neveljaven/Nepodprt URL', + 'Please wait...': 'Prosim počakaj...', + 'Login using email': 'Prijava z elektronskim naslovom', + 'Track removed from offline!': + 'Skladba odstranjena iz knjižnice brez povezave!', + 'Removed album from offline!': + 'Album odstranjen iz knjižnice brez povezave!', + 'Playlist removed from offline!': + 'Seznam predvajanja odstranjen iz knjižnice brez povezave!', + 'Repeat': 'Ponovi', + 'Repeat one': 'Ponovi enkrat', + 'Repeat off': 'Ponavljanje izklopljeno', + 'Love': 'Ljubim', + 'Unlove': 'Odljubi', + 'Dislike': 'Ni mi všeč', + 'Close': 'Zapri', + 'Sort playlist': 'Uredi seznam predvajanja', + 'Sort ascending': 'Razvrsti naraščajoče', + 'Sort descending': 'Razvrsti padajoče', + 'Stop': 'Ustavi', + 'Start': 'Začni', + 'Clear all': 'Počisti vse', + 'Play previous': 'Predvajaj prejšnjo', + 'Play': 'Predvajaj', + 'Pause': 'Pavza', + 'Remove': 'Odstrani', + 'Seekbar': 'Iskalna vrstica', + 'Singles': 'Singli', + 'Featured': 'Predstavljeni', + 'Fans': 'Oboževalci', + 'Duration': 'Trajanje', + 'Sort': 'Razvrsti', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Vaš ARL je mogoče potekel, poskusite se odjaviti in prijaviti nazaj z novim ARL ali brskalnikom.' + }, + 'es_es': { + 'Home': 'Inicio', + 'Search': 'Buscar', + 'Library': 'Biblioteca', + "Offline mode, can't play flow or smart track lists.": + 'Modo sin conexión. No se puede reproducir flow o las listas de canciones inteligentes.', + 'Added to library': 'Añadido a la biblioteca', + 'Download': 'Descargar', + 'Disk': 'Disco', + 'Offline': 'Sin conexión', + 'Top Tracks': 'Mejores canciones', + 'Show more tracks': 'Mostrar más canciones', + 'Top': 'Lo mejor', + 'Top Albums': 'Mejores álbumes', + 'Show all albums': 'Mostrar todos los álbumes', + 'Discography': 'Discografía', + 'Default': 'Predeterminado', + 'Reverse': 'Invertir', + 'Alphabetic': 'Alfabético', + 'Artist': 'Artista', + 'Post processing...': 'Post procesando...', + 'Done': 'Hecho', + 'Delete': 'Eliminar', + 'Are you sure you want to delete this download?': + '¿Estás seguro de que quieres borrar esta descarga?', + 'Cancel': 'Cancelar', + 'Downloads': 'Descargas', + 'Clear queue': 'Limpiar la cola', + "This won't delete currently downloading item": + 'Esto no eliminará el elemento que se está descargando actualmente', + 'Are you sure you want to delete all queued downloads?': + '¿Estás seguro que quieres eliminar todas las descargas en cola?', + 'Clear downloads history': 'Eliminar el historial de descargas', + 'WARNING: This will only clear non-offline (external downloads)': + 'ADVERTENCIA: Esto sólo borrará las descargas que no están en modo sin conexión (descargas externas).', + 'Please check your connection and try again later...': + 'Por favor compruebe su conexión e intente más tarde...', + 'Show more': 'Mostrar más', + 'Importer': 'Importador', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Actualmente sólo se admite Spotify con un límite de 100 canciones', + 'Due to API limitations': 'Debido a limitaciones de la API', + 'Enter your playlist link below': + 'Ingrese el enlace de su lista de reproducción a continuación', + 'Error loading URL!': '¡Error al cargar la URL!', + 'Convert': 'Convertir', + 'Download only': 'Solo descargar', + 'Downloading is currently stopped, click here to resume.': + 'La descarga está actualmente detenida, haga clic aquí para reanudarla.', + 'Tracks': 'Canciones', + 'Albums': 'Álbumes', + 'Artists': 'Artistas', + 'Playlists': 'Listas de reproducción', + 'Import': 'Importar', + 'Import playlists from Spotify': + 'Importar listas de reproducción de Spotify', + 'Statistics': 'Estadísticas', + 'Offline tracks': 'Canciones sin conexión', + 'Offline albums': 'Álbumes sin conexión', + 'Offline playlists': 'Listas de reproducción sin conexión', + 'Offline size': 'Tamaño sin conexión', + 'Free space': 'Espacio libre', + 'Loved tracks': 'Canciones favoritas', + 'Favorites': 'Favoritos', + 'All offline tracks': 'Todas las canciones sin conexión', + 'Create new playlist': 'Crear nueva lista de reproducción', + 'Cannot create playlists in offline mode': + 'No se pueden crear listas de reproducción en el modo sin conexión', + 'Error': 'Error', + 'Error logging in! Please check your token and internet connection and try again.': + '¡Error al iniciar la sesión! Por favor, compruebe su token y su conexión a Internet e inténtelo de nuevo.', + 'Dismiss': 'Descartar', + 'Welcome to': 'Bienvenido a', + 'Please login using your Deezer account.': + 'Por favor, inicie sesión con su cuenta de Deezer.', + 'Login using browser': 'Ingresar usando el navegador', + 'Login using token': 'Ingresar usando token', + 'Enter ARL': 'Ingrese ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Guardar', + "If you don't have account, you can register on deezer.com for free.": + 'Si no tienes una cuenta, puedes registrarte en deezer.com de forma gratuita.', + 'Open in browser': 'Abrir en el navegador', + "By using this app, you don't agree with the Deezer ToS": + 'Al usar esta aplicación, estás en desacuerdo con las Condiciones de servicio de Deezer', + 'Play next': 'Reproducir siguiente', + 'Add to queue': 'Añadir a la cola', + 'Add track to favorites': 'Agregar la canción a favoritos', + 'Add to playlist': 'Agregar a la lista de reproducción', + 'Select playlist': 'Seleccionar lista de reproducción', + 'Track added to': 'Canción añadida a', + 'Remove from playlist': 'Quitar de la lista de reproducción', + 'Track removed from': 'Canción eliminada de', + 'Remove favorite': 'Eliminar favorito', + 'Track removed from library': 'Canción eliminada de la biblioteca', + 'Go to': 'Ir a', + 'Make offline': 'Hacer sin conexión', + 'Add to library': 'Agregar a la biblioteca', + 'Remove album': 'Eliminar álbum', + 'Album removed': 'Álbum eliminado', + 'Remove from favorites': 'Eliminar de favoritos', + 'Artist removed from library': 'Artista eliminado de la biblioteca', + 'Add to favorites': 'Agregar a favoritos', + 'Remove from library': 'Eliminar de la biblioteca', + 'Add playlist to library': 'Agregar lista de reproducción a la biblioteca', + 'Added playlist to library': + 'Lista de reproducción agregada a la biblioteca', + 'Make playlist offline': 'Hacer lista de reproducción sin conexión', + 'Download playlist': 'Descargar lista de reproducción', + 'Create playlist': 'Crear lista de reproducción', + 'Title': 'Título', + 'Description': 'Descripción', + 'Private': 'Privado', + 'Collaborative': 'Colaborativo', + 'Create': 'Crear', + 'Playlist created!': 'Lista de reproducción creada!', + 'Playing from:': 'Reproduciendo desde:', + 'Queue': 'Cola', + 'Offline search': 'Búsqueda sin conexión', + 'Search Results': 'Resultados de la búsqueda', + 'No results!': 'No hay resultados!', + 'Show all tracks': 'Mostrar todas las canciones', + 'Show all playlists': 'Mostrar todas las listas de reproducción', + 'Settings': 'Ajustes', + 'General': 'General', + 'Appearance': 'Apariencia', + 'Quality': 'Calidad', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Actualmente', + 'Select theme': 'Seleccione el tema', + 'Dark': 'Oscuro', + 'Black (AMOLED)': 'Negro (AMOLED)', + 'Deezer (Dark)': 'Deezer (Oscuro)', + 'Primary color': 'Color primario', + 'Selected color': 'Color seleccionado', + 'Use album art primary color': + 'Usar el color primario de la carátula del álbum', + 'Warning: might be buggy': 'Advertencia: podría tener errores', + 'Mobile streaming': 'Transmisión móvil', + 'Wifi streaming': 'Transmisión WiFi', + 'External downloads': 'Descargas externas', + 'Content language': 'Lenguaje del contenido', + 'Not app language, used in headers. Now': + 'No es un lenguaje de la aplicación, se usa en los encabezados. Ahora', + 'Select language': 'Seleccione el idioma', + 'Content country': 'País del contenido', + 'Country used in headers. Now': 'País utilizado en los encabezados. Ahora', + 'Log tracks': 'Seguimiento de las canciones', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Envía los registros de escucha de las canciones a Deezer, habilítalo para que funciones como Flow funcionen correctamente', + 'Offline mode': 'Modo sin conexión', + 'Will be overwritten on start.': 'Se sobrescribirá al inicio.', + 'Error logging in, check your internet connections.': + 'Error al iniciar sesión, verifique su conexión a internet.', + 'Logging in...': 'Ingresando...', + 'Download path': 'Ruta de las descargas', + 'Downloads naming': 'Nombramiento de las descargas', + 'Downloaded tracks filename': + 'Nombre de archivo de las canciones descargadas', + 'Valid variables are': 'Las variables válidas son', + 'Reset': 'Reiniciar', + 'Clear': 'Limpiar', + 'Create folders for artist': 'Crear carpetas por artista', + 'Create folders for albums': 'Crear carpetas por álbumes', + 'Separate albums by discs': 'Separar los álbumes por discos', + 'Overwrite already downloaded files': + 'Sobrescribir los archivos ya descargados', + 'Copy ARL': 'Copiar ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copia el Token de usuario/Cookie ARL para su uso en otras aplicaciones.', + 'Copied': 'Copiado', + 'Log out': 'Cerrar sesión', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Debido a la incompatibilidad de los plugins, no se puede iniciar sesión con el navegador sin reiniciar.', + '(ARL ONLY) Continue': 'Continuar (SÓLO ARL)', + 'Log out & Exit': 'Cerrar sesión y salir', + 'Pick-a-Path': 'Escoger ruta', + 'Select storage': 'Seleccionar el almacenamiento', + 'Go up': 'Subir', + 'Permission denied': 'Permiso denegado', + 'Language': 'Idioma', + 'Language changed, please restart ReFreezer to apply!': + '¡El idioma se cambió, por favor reinicie ReFreezer para aplicarlo!', + 'Importing...': 'Importando...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + '¡La canción no está disponible en Deezer!', + 'Failed to download track! Please restart.': + '¡Error al descargar la canción! Por favor, reintente.', + 'Storage permission denied!': 'Permiso de almacenamiento denegado!', + 'Failed': 'Fallido', + 'Queued': 'Puesto en cola', + 'External': 'Almacenamiento', + 'Restart failed downloads': 'Reiniciar descargas fallidas', + 'Clear failed': 'Limpiar fallidas', + 'Download Settings': 'Opciones de descarga', + 'Create folder for playlist': 'Crear carpeta para lista de reproducción', + 'Download .LRC lyrics': 'Descargar .LRC para líricas', + 'Proxy': 'Proxy', + 'Not set': 'No establecido', + 'Search or paste URL': 'Buscar o pegar URL', + 'History': 'Historial', + 'Download threads': 'Descargas simultáneas', + 'Lyrics unavailable, empty or failed to load!': + 'Letras no disponibles, vacías o no se pudieron cargar!', + 'About': 'Acerca de', + 'Telegram Channel': 'Canal de Telegram', + 'To get latest releases': 'Para obtener los últimos lanzamientos', + 'Official chat': 'Chat oficial', + 'Telegram Group': 'Grupo de Telegram', + 'Huge thanks to all the contributors! <3': + '¡Muchas gracias a todos los colaboradores! <3', + 'Edit playlist': 'Editar lista de reproducción', + 'Update': 'Actualizar', + 'Playlist updated!': 'Lista de reproducción actualizada!', + 'Downloads added!': 'Descargas agregadas!', + 'Save cover file for every track': + 'Guardar archivo de portada para cada canción', + 'Download Log': 'Registro de Descarga', + 'Repository': 'Repositorio', + 'Source code, report issues there.': + 'Código fuente, reporten problemas allí.', + 'Use system theme': 'Usar tema del sistema', + 'Light': 'Claro', + 'Popularity': 'Popularidad', + 'User': 'Usuario', + 'Track count': 'Número de canción', + "If you want to use custom directory naming - use '/' as directory separator.": + "Si quieres usar un nombre de directorio personalizado, usa '/' como separador de directorios.", + 'Share': 'Compartir', + 'Save album cover': 'Guardar portada de album', + 'Warning': 'Precaución', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + '¡Usar demasiadas descargas simultáneas en dispositivos antiguos podría causar fallos!', + 'Create .nomedia files': 'Crear archivos .nomedia', + 'To prevent gallery being filled with album art': + 'Para prevenir que la galería se llene con arte del album', + 'Sleep timer': 'Temporizador de apagado', + 'Minutes:': 'Minutos:', + 'Hours:': 'Horas:', + 'Cancel current timer': 'Cancelar temporizador actual', + 'Current timer ends at': 'El temporizador actual termina en', + 'Smart track list': 'Lista de canciones inteligentes', + 'Shuffle': 'Aleatorio', + 'Library shuffle': 'Reproducción aleatoria de la biblioteca', + 'Ignore interruptions': 'Ignorar interrupciones', + 'Requires app restart to apply!': 'Requiere reiniciar la app para aplicar!', + 'Ask before downloading': 'Preguntar antes de descargar', + 'Search history': 'Buscar historial', + 'Clear search history': 'Borrar historial de búsqueda', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Inicie sesión para habilitar scrobbling.', + 'Login to LastFM': 'Iniciar sesión con LastFM', + 'Username': 'Nombre de usario', + 'Password': 'Contraseña', + 'Login': 'Iniciar sesión', + 'Authorization error!': '¡Error de autorización!', + 'Logged out!': '¡Sesión cerrada!', + 'Lyrics': 'Letras', + 'Player gradient background': 'Fondo degradado en reproductor', + 'Updates': 'Actualizaciones', + 'You are running latest version!': '¡Estás usando la última versión!', + 'New update available!': '¡Nueva actualización disponible!', + 'Current version: ': 'Versión actual: ', + 'Unsupported platform!': '¡Plataforma no soportada!', + 'Freezer Updates': 'Actualizaciones de Freezer', + 'Update to latest version in the settings.': + 'Actualiza a la última versión en la configuración.', + 'Release date': 'Fecha de lanzamiento', + 'Shows': 'Shows', + 'Charts': 'Tablas', + 'Browse': 'Buscar', + 'Quick access': 'Acceso rápido', + 'Play mix': 'Reproducir mezcla', + 'Share show': 'Compartir show', + 'Date added': 'Fecha de adición', + 'Discord': 'Discord', + 'Official Discord server': 'Servidor oficial de Discord', + 'Restart of app is required to properly log out!': + '¡Reiniciar es necesario para cerrar la sesión correctamente!', + 'Artist separator': 'Separador de artista', + 'Singleton naming': 'Nombre del singleton', + 'Keep the screen on': 'Mantener la pantalla encendida', + 'Wakelock enabled!': '¡Wakelock activado!', + 'Wakelock disabled!': '¡Wakelock desactivado!', + 'Show all shows': 'Mostrar todos los shows', + 'Episodes': 'Episodios', + 'Show all episodes': 'Mostrar todos los episodios', + 'Album cover resolution': 'Resolución de la portada del álbum', + "WARNING: Resolutions above 1200 aren't officially supported": + 'ATENCIÓN: Resoluciones superiores a 1200 no están soportadas oficialmente', + 'Album removed from library!': '¡Álbum eliminado de la biblioteca!', + 'Remove offline': 'Eliminar sin conexión', + 'Playlist removed from library!': + '¡Lista de reproducción eliminada de la biblioteca!', + 'Blur player background': 'Desenfocar el fondo del reproductor', + 'Might have impact on performance': 'Puede tener impacto en el rendimiento', + 'Font': 'Fuente', + 'Select font': 'Seleccionar fuente', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Esta aplicación no está hecha para soportar muchas fuentes, puede romper diseños y desbordarse. ¡Usar bajo tu propio riesgo!', + 'Enable equalizer': 'Activar ecualizador', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Podría habilitar algunas aplicaciones de ecualización para funcionar. Requiere reiniciar Freezer', + 'Visualizer': 'Visualizador', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Mostrar visualizadores en la página de letras. ADVERTENCIA: ¡Requiere permiso del micrófono!', + 'Tags': 'Etiquetas', + 'Album': 'Álbum', + 'Track number': 'Número de pista', + 'Disc number': 'Número de disco', + 'Album artist': 'Artista del álbum', + 'Date/Year': 'Fecha/Año', + 'Label': 'Etiqueta', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Total de pistas', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Letras sin sincronizar', + 'Genre': 'Género', + 'Contributors': 'Colaboradores', + 'Album art': 'Arte del álbum', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer no está disponible en tu país, ReFreezer puede no funcionar correctamente. Por favor usa una VPN', + 'Deezer is unavailable': 'Deezer no está disponible', + 'Continue': 'Continuar', + 'Email Login': 'Ingreso con Email', + 'Email': 'Email', + 'Missing email or password!': '¡Falta email o contraseña!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error al iniciar sesión utilizando el email, comprueba tus credenciales.\nError:', + 'Error logging in!': '¡Error al iniciar sesión!', + 'Change display mode': 'Cambiar modo de visualización', + 'Enable high refresh rates': 'Activar altas tasas de refresco', + 'Display mode': 'Modo de visualización', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Importar listas de reproducción de Spotify hasta 100 pistas sin iniciar sesión.', + 'Download imported tracks': 'Descargar pistas importadas', + 'Start import': 'Iniciar importación', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Importar cualquier lista de reproducción de Spotify, importar desde la propia biblioteca de Spotify. Requiere cuenta gratuita.', + 'Spotify Importer v2': 'Importador de Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Este importador requiere el ID de cliente de Spotify y el secreto del cliente. Para obtenerlos:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Ir a: developer.spotify.com/dashboard y crear una aplicación.', + 'Open in Browser': 'Abrir en el navegador', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. En la aplicación que acaba de crear ir a la configuración y establecer la URL de redirección a: ', + 'Copy the Redirect URL': 'Copiar la URL de redirección', + 'Client ID': 'ID del cliente', + 'Client Secret': 'Secreto del cliente', + 'Authorize': 'Autorizar', + 'Logged in as: ': 'Sesión iniciada como: ', + 'Import playlists by URL': 'Importar listas de reproducción por URL', + 'URL': 'URL', + 'Options': 'Opciones', + 'Invalid/Unsupported URL': 'URL inválida/no soportada', + 'Please wait...': 'Por favor espera...', + 'Login using email': 'Iniciar sesión con email', + 'Track removed from offline!': '¡Canción eliminada sin conexión!', + 'Removed album from offline!': '¡Álbum eliminado sin conexión!', + 'Playlist removed from offline!': + '¡Lista de reproducción eliminada sin conexión!', + 'Repeat': 'Repetir', + 'Repeat one': 'Repetir una vez', + 'Repeat off': 'No repetir', + 'Love': 'Favorita', + 'Unlove': 'Eliminar favorita', + 'Dislike': 'No me gusta', + 'Close': 'Cerrar', + 'Sort playlist': 'Ordenar lista de reproducción', + 'Sort ascending': 'Orden ascendente', + 'Sort descending': 'Orden descendente', + 'Stop': 'Parar', + 'Start': 'Iniciar', + 'Clear all': 'Limpiar todo', + 'Play previous': 'Reproducir anterior', + 'Play': 'Reproducir', + 'Pause': 'Pausar', + 'Remove': 'Quitar', + 'Seekbar': 'Barra de búsqueda', + 'Singles': 'Sencillos', + 'Featured': 'Destacados', + 'Fans': 'Admiradores', + 'Duration': 'Duración', + 'Sort': 'Ordenar', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Puede que tu ARL haya caducado, prueba a cerrar la sesión y volver a iniciar sesión usando ARL o navegador.' + }, + 'tr_tr': { + 'Home': 'Ana Sayfa', + 'Search': 'Ara', + 'Library': 'Kütüphane', + "Offline mode, can't play flow or smart track lists.": + 'Çevrimdışı modu, flow veya akıllı parça listeleri çalınamaz.', + 'Added to library': 'Kütüphaneye eklendi', + 'Download': 'İndir', + 'Disk': 'Disk', + 'Offline': 'Çevrimdışı', + 'Top Tracks': 'Popülar Parçalar', + 'Show more tracks': 'Daha fazla parça göster', + 'Top': 'En popüler', + 'Top Albums': 'Popüler Albümler', + 'Show all albums': 'Tüm albümleri göster', + 'Discography': 'Diskografi', + 'Default': 'Varsayılan', + 'Reverse': 'Tersten', + 'Alphabetic': 'Alfabetik', + 'Artist': 'Sanatçı', + 'Post processing...': 'İşleniyor...', + 'Done': 'Bitti', + 'Delete': 'Sil', + 'Are you sure you want to delete this download?': + 'Bu indirmeyi silmek istediğinizden emin misiniz?', + 'Cancel': 'İptal', + 'Downloads': 'İndirilenler', + 'Clear queue': 'Sırayı temizle', + "This won't delete currently downloading item": + 'Şu anda indirilen parçayı silmez', + 'Are you sure you want to delete all queued downloads?': + 'Sıradaki tüm indirmeleri silmek istediğinizden emin misiniz?', + 'Clear downloads history': 'İndirme geçmişini temizle', + 'WARNING: This will only clear non-offline (external downloads)': + 'UYARI: Bu yalnızca çevrimdışı olmayanları temizler (harici indirmeler)', + 'Please check your connection and try again later...': + 'Lütfen bağlantınızı kontrol ediniz ve daha sonra tekrar deneyiniz...', + 'Show more': 'Daha fazla göster', + 'Importer': 'Aktarıcı', + 'Currently supporting only Spotify, with 100 tracks limit': + "Şu anda 100 parça sınırıyla yalnızca Spotify'ı destekliyor", + 'Due to API limitations': 'API sınırlamaları nedeniyle', + 'Enter your playlist link below': + 'Oynatma listesi bağlantınızı aşağıya giriniz', + 'Error loading URL!': 'URL yüklenirken hata oluştu!', + 'Convert': 'Dönüştür', + 'Download only': 'Sadece indir', + 'Downloading is currently stopped, click here to resume.': + 'İndirme durduruldu, devam etmek için tıklayın.', + 'Tracks': 'Parçalar', + 'Albums': 'Albümler', + 'Artists': 'Sanatçılar', + 'Playlists': 'Çalma listeleri', + 'Import': 'İçe Aktar', + 'Import playlists from Spotify': + "Spotify'dan çalma listelerini içe aktarın", + 'Statistics': 'İstatistikler', + 'Offline tracks': 'Çevrimdışı parçalar', + 'Offline albums': 'Çevrimdışı albümler', + 'Offline playlists': 'Çevrimdışı oynatma listeleri', + 'Offline size': 'Çevrimdışı boyut', + 'Free space': 'Boş alan', + 'Loved tracks': 'Sevilen parçalar', + 'Favorites': 'Favoriler', + 'All offline tracks': 'Tüm çevrimdışı parçalar', + 'Create new playlist': 'Yeni çalma listesi oluştur', + 'Cannot create playlists in offline mode': + 'Çevrimdışı modda oynatma listeleri oluşturulamaz', + 'Error': 'Hata', + 'Error logging in! Please check your token and internet connection and try again.': + 'Oturum açılamadı! Lütfen tokeninizi ve internet bağlantınızı kontrol edin ve tekrar deneyin.', + 'Dismiss': 'Kapat', + 'Welcome to': 'Hoşgeldiniz', + 'Please login using your Deezer account.': + 'Lütfen Deezer hesabınızı kullanarak giriş yapın.', + 'Login using browser': 'Tarayıcı kullanarak giriş yapın', + 'Login using token': 'Token kullanarak giriş yap', + 'Enter ARL': 'ARL girin', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Kaydet', + "If you don't have account, you can register on deezer.com for free.": + "Hesabınız yoksa deezer.com'a ücretsiz kayıt olabilirsiniz.", + 'Open in browser': 'Tarayıcıda aç', + "By using this app, you don't agree with the Deezer ToS": + "Bu uygulamayı kullanarak Deezer Hizmet Şartları'nı kabul etmiyorsunuz", + 'Play next': 'Sonrakini çal', + 'Add to queue': 'Sıraya ekle', + 'Add track to favorites': 'Parçayı favorilere ekle', + 'Add to playlist': 'Çalma listesine ekle', + 'Select playlist': 'Çalma listesi seçin', + 'Track added to': 'Parça şuraya eklendi', + 'Remove from playlist': 'Çalma listesinden kaldır', + 'Track removed from': 'Parça şuradan kaldırıldı', + 'Remove favorite': 'Favorilerden kaldır', + 'Track removed from library': 'Parça kütüphaneden kaldırıldı', + 'Go to': 'Git', + 'Make offline': 'Çevrimdışı yap', + 'Add to library': 'Kütüphaneye ekle', + 'Remove album': 'Albümü kaldır', + 'Album removed': 'Albüm kaldırıldı', + 'Remove from favorites': 'Favorilerden kaldır', + 'Artist removed from library': 'Sanatçı kütüphaneden kaldırıldı', + 'Add to favorites': 'Favorilere ekle', + 'Remove from library': 'Kütüphaneden kaldır', + 'Add playlist to library': 'Çalma listesini kütüphaneye ekleyin', + 'Added playlist to library': 'Çalma listesi kütüphaneye eklendi', + 'Make playlist offline': 'Çalma listesini çevrimdışı yapın', + 'Download playlist': 'Çalma listesini indirin', + 'Create playlist': 'Çalma listesi oluştur', + 'Title': 'Başlık', + 'Description': 'Açıklama', + 'Private': 'Özel', + 'Collaborative': 'Paylaşılan', + 'Create': 'Oluştur', + 'Playlist created!': 'Çalma listesi oluşturuldu!', + 'Playing from:': 'Şuradan oynatılıyor:', + 'Queue': 'Kuyruk', + 'Offline search': 'Çevrimdışı arama', + 'Search Results': 'Arama Sonuçları', + 'No results!': 'Sonuç yok!', + 'Show all tracks': 'Tüm parçaları göster', + 'Show all playlists': 'Tüm çalma listelerini göster', + 'Settings': 'Ayarlar', + 'General': 'Genel', + 'Appearance': 'Arayüz', + 'Quality': 'Kalite', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Şu anda', + 'Select theme': 'Tema seçin', + 'Dark': 'Koyu', + 'Black (AMOLED)': 'Siyah (AMOLED)', + 'Deezer (Dark)': 'Deezer (Dark)', + 'Primary color': 'Ana renk', + 'Selected color': 'Seçilen renk', + 'Use album art primary color': 'Albüm resmini ana renk olarak kullan', + 'Warning: might be buggy': 'Uyarı: hatalı olabilir', + 'Mobile streaming': 'Mobilde yayınlama', + 'Wifi streaming': 'Wi-Fi', + 'External downloads': 'Harici indirmeler', + 'Content language': 'İçerik dili', + 'Not app language, used in headers. Now': + 'Uygulama dili değil, başlıklarda kullanılacak. Şuan', + 'Select language': 'Dil seçin', + 'Content country': 'İçerik ülkesi', + 'Country used in headers. Now': 'Başlıklarda kullanılan ülke. Şuan', + 'Log tracks': 'Parça günlükleri', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + "Parça dinleme günlüklerini Deezer'a gönderin, Flow gibi özelliklerin düzgün çalışması için etkinleştirin", + 'Offline mode': 'Çevrimdışı modu', + 'Will be overwritten on start.': 'Başlangıçta üzerine yazılacak.', + 'Error logging in, check your internet connections.': + 'Giriş hatası, internet bağlantılarınızı kontrol edin.', + 'Logging in...': 'Giriş yapılıyor...', + 'Download path': 'İndirme konumu', + 'Downloads naming': 'İndirilenleri adlandır', + 'Downloaded tracks filename': 'İndirilen parçaların dosya adı', + 'Valid variables are': 'Geçerli değişkenler', + 'Reset': 'Sıfırla', + 'Clear': 'Temizle', + 'Create folders for artist': 'Sanatçılar için klasörler oluştur', + 'Create folders for albums': 'Albümler için klasörler oluştur', + 'Separate albums by discs': 'Albümleri disklere göre ayırın', + 'Overwrite already downloaded files': 'İndirilmiş dosyaların üzerine yaz', + 'Copy ARL': 'ARL kopyala', + 'Copy userToken/ARL Cookie for use in other apps.': + "Diğer uygulamalarda kullanmak için userToken / ARL Cookie'yi kopyalayın.", + 'Copied': 'Kopyalandı', + 'Log out': 'Çıkış yap', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Eklenti uyumsuzluğu nedeniyle, yeniden başlatmadan tarayıcı kullanılarak oturum açılamaz.', + '(ARL ONLY) Continue': '(SADECE ARL) Devam et', + 'Log out & Exit': 'Çıkış yap & Kapat', + 'Pick-a-Path': 'Konum seç', + 'Select storage': 'Depolama seç', + 'Go up': 'Yukarı git', + 'Permission denied': 'İzin reddedildi', + 'Language': 'Dil', + 'Language changed, please restart ReFreezer to apply!': + 'Dil değişti,değişiklik için Freezeri yeniden başlatın!', + 'Importing...': 'İçe aktarılıyor...', + 'Radio': 'Radyo', + 'Flow': 'Akış', + 'Track is not available on Deezer!': "Parça Deezer'da mevcut değil!", + 'Failed to download track! Please restart.': + 'Parça indirilemedi! Lütfen yeniden başlat.', + 'Storage permission denied!': 'Depolama izni reddedildi!', + 'Failed': 'Başarısız', + 'Queued': 'Sıraya alındı', + 'External': 'Depolama', + 'Restart failed downloads': 'Başarısız indirmeleri yeniden başlatın', + 'Clear failed': 'Silinemedi', + 'Download Settings': 'İndirme Ayarları', + 'Create folder for playlist': 'Çalma listesi için klasör oluştur', + 'Download .LRC lyrics': '.LRC şarkı sözlerini indir', + 'Proxy': 'Proxy', + 'Not set': 'Ayarlanmadı', + 'Search or paste URL': 'Arayın veya URL yapıştırın', + 'History': 'Geçmiş', + 'Download threads': 'Eşzamanlı indirmeler', + 'Lyrics unavailable, empty or failed to load!': + 'Sözler mevcut değil, boş veya yüklenemedi!', + 'About': 'Hakkında', + 'Telegram Channel': 'Telegram Kanalı', + 'To get latest releases': 'En son sürümleri indirmek için', + 'Official chat': 'Resmi sohbet', + 'Telegram Group': 'Telegram Grubu', + 'Huge thanks to all the contributors! <3': + 'Katkıda bulunanlara çok teşekkür ederiz! <3', + 'Edit playlist': 'Çalma listesini düzenleyin', + 'Update': 'Güncelle', + 'Playlist updated!': 'Çalma listesi güncellendi!', + 'Downloads added!': 'İndirmeler eklendi!', + 'Save cover file for every track': + 'Her parça için kapak dosyasını kaydedin', + 'Download Log': 'İndirme Kayıtları', + 'Repository': 'Repo', + 'Source code, report issues there.': 'Kaynak kodu, sorunları bildirin', + 'Use system theme': 'Sistem temasını kullan', + 'Light': 'Açık', + 'Popularity': 'Popüler', + 'User': 'Kullanıcı', + 'Track count': 'Şarkı sayısı', + "If you want to use custom directory naming - use '/' as directory separator.": + "Özel dizin adlandırma kullanmak istiyorsanız, dizin ayırıcı olarak '/' kullanın.", + 'Share': 'Paylaş', + 'Save album cover': 'Albüm kapağını kaydet', + 'Warning': 'Uyarı', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Çok eski veya güçsüz cihazlarda çok fazla eşzamanlı indirme kullanmak çökmelere neden olabilir!', + 'Create .nomedia files': '.Nomedia dosyaları oluşturun', + 'To prevent gallery being filled with album art': + 'Galerinin albüm kapağıyla dolmasını önlemek için', + 'Sleep timer': 'Uyku zamanlayıcısı', + 'Minutes:': 'Dakika:', + 'Hours:': 'Saat:', + 'Cancel current timer': 'Geçerli zamanlayıcıyı iptal et', + 'Current timer ends at': 'Zamanlayıcı bitiş saati', + 'Smart track list': 'Akıllı parça listesi', + 'Shuffle': 'Karışık çal', + 'Library shuffle': 'Kütüphane Karışık', + 'Ignore interruptions': 'Kesmeleri görmezden gel', + 'Requires app restart to apply!': + '* Uygulanması için yeniden başlatmak gerekir!', + 'Ask before downloading': 'İndirmeden önce sor', + 'Search history': 'Arama geçmişi', + 'Clear search history': 'Arama geçmişini temizle', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': + "Scrobbling'i etkinleştirmek için giriş yap.", + 'Login to LastFM': 'LastFM ile giriş yap', + 'Username': 'Kullanıcı adı', + 'Password': 'Şifre', + 'Login': 'Giriş yap', + 'Authorization error!': 'Giriş esnasında bir hata oluştu!', + 'Logged out!': 'Oturum kapatıldı!', + 'Lyrics': 'Şarkı sözleri', + 'Player gradient background': 'Çaların gradient arka fonu', + 'Updates': 'Güncellemeler', + 'You are running latest version!': 'En son sürümü kullanıyorsunuz!', + 'New update available!': 'Yeni güncelleme mevcut!', + 'Current version: ': 'Mevcut sürüm: ', + 'Unsupported platform!': 'Desteklenmeyen platform!', + 'Freezer Updates': 'Freezer Güncellemeleri', + 'Update to latest version in the settings.': + 'Ayarlarda en son sürüme kadar güncelleyebilirsiniz.', + 'Release date': 'Yayınlanma tarihi', + 'Shows': 'Podcast’ler', + 'Charts': 'Popüler müzik listeleri', + 'Browse': 'Gözat', + 'Quick access': 'Hızlı erişim', + 'Play mix': 'Mix çal', + 'Share show': 'Gösteriyi paylaş', + 'Date added': 'Eklenme tarihi', + 'Discord': 'Discord', + 'Official Discord server': 'Resmi Discord sunucusu', + 'Restart of app is required to properly log out!': + 'Oturumun düzgün bir şekilde kapatılması için uygulamanın yeniden başlatılması gerekiyor!', + 'Artist separator': 'Sanatçı ayırıcı', + 'Singleton naming': 'Şarkıların adlandırma taslağı', + 'Keep the screen on': 'Ekranı açık tutma', + 'Wakelock enabled!': 'Uyandırma kilidi etkin!', + 'Wakelock disabled!': 'Uyandırma kilidi devre dışı!', + 'Show all shows': 'Tüm podcast’leri göster', + 'Episodes': 'Bölümler', + 'Show all episodes': 'Tüm bölümleri göster', + 'Album cover resolution': 'Albüm kapağı çözünürlüğü', + "WARNING: Resolutions above 1200 aren't officially supported": + "UYARI: 1200'ün üzerindeki çözünürlükler resmi olarak desteklenmiyor", + 'Album removed from library!': 'Albüm kütüphaneden kaldırıldı!', + 'Remove offline': 'Çevrimdışı kaldır', + 'Playlist removed from library!': + 'Oynatma listesi kütüphaneden kaldırıldı!', + 'Blur player background': 'Bulanık oynatıcı arkaplanı', + 'Might have impact on performance': 'Performans üzerinde etkisi olabilir', + 'Font': 'Yazı tipi', + 'Select font': 'Yazı tipi seç', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Bu uygulama birçok yazı tipini desteklemek için yapılmamıştır; düzenler bozulabilir ve taşabilir. Kullanım kendi sorumluluğunuzdadır!', + 'Enable equalizer': 'Ekolayzırı etkinleştir', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + "Bazı ekolayzır uygulamalarının çalışmasını sağlayabilir. Freezer'ın yeniden başlatılmasını gerektirir", + 'Visualizer': 'Görselleştirme', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Görselleştiricileri şarkı sözleri sayfasında gösterin. UYARI: Mikrofon izni gerektirir!', + 'Tags': 'Etiketler', + 'Album': 'Albüm', + 'Track number': 'Parça numarası', + 'Disc number': 'Disk numarası', + 'Album artist': 'Albüm sanatçısı', + 'Date/Year': 'Tarih/Yıl', + 'Label': 'Etiket', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Parça toplamı', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Senkronize edilmemiş şarkı sözleri', + 'Genre': 'Tür', + 'Contributors': 'Katkıda bulunanlar', + 'Album art': 'Albüm kapağı', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer ülkenizde mevcut değildir, ReFreezer düzgün çalışmayabilir. Lütfen bir VPN kullanın', + 'Deezer is unavailable': 'Deezer kullanılamıyor', + 'Continue': 'Devam et', + 'Email Login': 'E-posta girişi', + 'Email': 'E-posta', + 'Missing email or password!': 'E-posta ya da şifre eksik!', + 'Error logging in using email, please check your credentials.\nError:': + 'E-posta ile giriş yaparken hata oluştu, lütfen kimlik bilgilerinizi kontrol edin.\nHata:', + 'Error logging in!': 'Oturum açma hatası!', + 'Change display mode': 'Görünüm modunu değiştir', + 'Enable high refresh rates': 'Yüksek yenileme oranını etkinleştir', + 'Display mode': 'Görünüm modu', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Giriş yapmadan Spotify çalma listelerini 100 parçaya kadar içe aktarın.', + 'Download imported tracks': 'İçe aktarılan parçaları indir', + 'Start import': 'İçe aktarmayı başlat', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + "Herhangi bir Spotify çalma listesini içe aktarın, Spotify'ın kütüphanesinden içe aktarın. Ücretsiz hesap gerektirir.", + 'Spotify Importer v2': 'Spotify İçe Aktarıcı v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Bu içe aktarıcı Spotify Client ID ve Client Secret gerektirir. Onları elde etmek için:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. developer.spotify.com/dashboard adresine gidin ve bir uygulama oluşturun.', + 'Open in Browser': 'Tarayıcıda Aç', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + "2. Yeni oluşturduğunuz uygulamada Ayarlar'a gidin ve Yönlendirme URL'sini şu şekilde ayarlayın: ", + 'Copy the Redirect URL': "Yönlendirme URL'sini Kopyala", + 'Client ID': 'İstemci ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'İzin ver', + 'Logged in as: ': 'Giriş yapıldı: ', + 'Import playlists by URL': 'Çalma listelerini URL ile içe aktar', + 'URL': 'URL', + 'Options': 'Seçenekler', + 'Invalid/Unsupported URL': 'Geçersiz/Desteklenmeyen URL', + 'Please wait...': 'Lütfen bekleyin...', + 'Login using email': 'E-posta ile giriş yap', + 'Track removed from offline!': + 'Parça çevrim dışı kitaplığından kaldırıldı!', + 'Removed album from offline!': + 'Albüm çevrim dışı kitaplığından kaldırıldı!', + 'Playlist removed from offline!': + 'Çalma listesi çevrim dışı kitaplığından kaldırıldı!', + 'Repeat': 'Tekrarla', + 'Repeat one': 'Bir kez tekrarla', + 'Repeat off': 'Tekrarlama kapalı', + 'Love': 'Sevdim', + 'Unlove': 'Sevmedim', + 'Dislike': 'Beğenmedim', + 'Close': 'Kapat', + 'Sort playlist': 'Çalma listesine göre sırala', + 'Sort ascending': 'Artana göre sırala', + 'Sort descending': 'Azalana göre sırala', + 'Stop': 'Durdur', + 'Start': 'Başlat', + 'Clear all': 'Tümünü temizle', + 'Play previous': 'Öncekini oynat', + 'Play': 'Oynat', + 'Pause': 'Duraklat', + 'Remove': 'Kaldır', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Öne çıkanlar', + 'Fans': 'Hayranlar', + 'Duration': 'Süre', + 'Sort': 'Sırala', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'uk_ua': { + 'Home': 'Головна', + 'Search': 'Пошук', + 'Library': 'Бібліотека', + "Offline mode, can't play flow or smart track lists.": + 'Офлайн-режим, неможливо відтворити потік або smart track списки.', + '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': 'Вибрати тему', + 'Dark': 'Темна', + 'Black (AMOLED)': 'Чорна (AMOLED)', + 'Deezer (Dark)': 'Deezer (темна)', + 'Primary color': 'Основний колір', + 'Selected color': 'Вибраний колір', + 'Use album art primary color': + 'Використовувати основний колір обкладинки альбому', + 'Warning: might be buggy': 'Увага: може виникнути помилка', + 'Mobile streaming': 'Мобільний потік', + 'Wifi streaming': 'Wifi потік', + '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.': + 'Скопіювати 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': 'Вибрати шлях', + 'Select storage': 'Вибрати сховище', + 'Go up': 'Вгору', + 'Permission denied': 'Доступ заборонено', + 'Language': 'Мова', + 'Language changed, please restart ReFreezer to apply!': + 'Мову змінено, будь ласка, перезапустіть ReFreezer для застосування!', + 'Importing...': 'Імпорт...', + 'Radio': 'Радіо', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Трек не доступний в Deezer!', + 'Failed to download track! Please restart.': + 'Не вдалося завантажити трек! Будь ласка, перезапустіть.', + 'Storage permission denied!': 'Немає дозволу на доступ до сховища!', + 'Failed': 'Помилка', + 'Queued': 'У черзі', + 'External': 'Сховище', + 'Restart failed downloads': 'Перезапустити невдалі завантаження', + 'Clear failed': 'Очистити невдалі', + 'Download Settings': 'Параметри завантаження', + 'Create folder for playlist': 'Створити теку для плейлиста', + 'Download .LRC lyrics': 'Завантажити .LRC тексти пісень', + 'Proxy': 'Проксі', + 'Not set': 'Не вибрано', + 'Search or paste URL': 'Введіть адресу або пошуковий запит', + 'History': 'Історія', + 'Download threads': 'Одночасних завантажень', + 'Lyrics unavailable, empty or failed to load!': + 'Текст недоступний, відсутній або завантаження не вдалося!', + 'About': 'Про додаток', + 'Telegram Channel': 'Telegram канал', + 'To get latest releases': 'Для отримання останніх релізів', + 'Official chat': 'Офіційний чат', + 'Telegram Group': 'Група в Telegram', + 'Huge thanks to all the contributors! <3': + 'Величезна подяка всім учасникам! <3', + 'Edit playlist': 'Редагувати плейлист', + 'Update': 'Оновити', + 'Playlist updated!': 'Плейлист оновлено!', + 'Downloads added!': 'Завантаження додано!', + 'Save cover file for every track': + 'Зберегти файл обкладинки для кожного треку', + 'Download Log': 'Журнал завантажень', + 'Repository': 'Репозиторій', + 'Source code, report issues there.': + 'Вихідний код, повідомте про проблеми.', + 'Use system theme': 'Використовувати системну тему', + 'Light': 'Світла', + 'Popularity': 'Популярне', + 'User': 'Користувач', + 'Track count': 'Кількість треків', + "If you want to use custom directory naming - use '/' as directory separator.": + "Якщо ви хочете використовувати назву власної директорії - використовуйте '/' як розділювач каталогів.", + 'Share': 'Поділитись', + 'Save album cover': 'Зберегти обкладинку альбому', + 'Warning': 'Увага', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Занадто велика кількість одночасних завантажень на старих/слабких пристроях може спричинити збій!', + 'Create .nomedia files': 'Створити файли .nomedia', + 'To prevent gallery being filled with album art': + 'Щоб запобігти заповненню галереї обкладинками альбомів', + 'Sleep timer': 'Таймер сну', + 'Minutes:': 'Хвилини:', + 'Hours:': 'Години:', + 'Cancel current timer': 'Скасувати поточний таймер', + 'Current timer ends at': 'Поточний таймер закінчується о', + 'Smart track list': 'Smart track лист', + 'Shuffle': 'Перемішати', + 'Library shuffle': 'Перемішати в бібліотеці', + 'Ignore interruptions': 'Ігнорувати переривання', + 'Requires app restart to apply!': 'Потрібен перезапуск додатку!', + 'Ask before downloading': 'Запитувати перед завантаженням', + 'Search history': 'Історія пошуку', + 'Clear search history': 'Очистити історію пошуку', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Увійдіть, щоб увімкнути scrobbling.', + 'Login to LastFM': 'Увійти через LastFM', + 'Username': "Ім'я користувача", + 'Password': 'Пароль', + 'Login': 'Увійти', + 'Authorization error!': 'Помилка авторизації!', + 'Logged out!': 'Вихід з системи!', + 'Lyrics': 'Тексти пісень', + 'Player gradient background': 'Градієнт фону', + 'Updates': 'Оновлення', + 'You are running latest version!': 'Ви використовуєте останню версію!', + 'New update available!': 'Доступне оновлення!', + 'Current version: ': 'Поточна версія: ', + 'Unsupported platform!': 'Непідтримувана платформа!', + 'Freezer Updates': 'Оновлення Freezer', + 'Update to latest version in the settings.': + 'Оновити до останньої версії в налаштуваннях.', + 'Release date': 'Дата релізу', + 'Shows': 'Подкасти', + 'Charts': 'Чарти', + 'Browse': 'Перегляд', + 'Quick access': 'Швидкий доступ', + 'Play mix': 'Грати мікс', + 'Share show': 'Поділитись подкастом', + 'Date added': 'Дата додавання', + 'Discord': 'Discord', + 'Official Discord server': 'Офіційний сервер Discord', + 'Restart of app is required to properly log out!': + 'Для коректного виходу потрібен перезапуск!', + 'Artist separator': 'Роздільник виконавця', + 'Singleton naming': 'Унікальна назва', + 'Keep the screen on': 'Не вимикати екран', + 'Wakelock enabled!': 'Wakelock увімкнено!', + 'Wakelock disabled!': 'Wakelock вимкнено!', + 'Show all shows': 'Показати всі композиції', + 'Episodes': 'Епізоди', + 'Show all episodes': 'Показати всі епізоди', + 'Album cover resolution': 'Роздільна здатність обкладинки альбому', + "WARNING: Resolutions above 1200 aren't officially supported": + 'Увага: Роздільна здатність вище 1200 офіційно не підтримується', + 'Album removed from library!': 'Альбом видалено з бібліотеки!', + 'Remove offline': 'Видалити офлайн', + 'Playlist removed from library!': 'Плейлист видалено з бібліотеки!', + 'Blur player background': 'Розмити фон плеєра', + 'Might have impact on performance': 'Може вплинути на продуктивність', + 'Font': 'Шрифт', + 'Select font': 'Обрати шрифт', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Обережно, використання зовнішніх шрифтів може зламати розмітку і зовнішній вигляд програми!', + 'Enable equalizer': 'Увімкнути еквалайзер', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Може допомогти, якщо використовується не системний еквалайзер. потрібен перезапуск Freezer', + 'Visualizer': 'Візуалізація', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Відображати візуалізацію на на екрані тексту. Потрібен дозвіл \"Доступ до мікрофона\"!', + 'Tags': 'Теги', + 'Album': 'Альбом', + 'Track number': 'Номер треку', + 'Disc number': 'Номер диску', + 'Album artist': 'Виконавець', + 'Date/Year': 'Дата/рік', + 'Label': 'Видавець', + 'ISRC': 'ISRC-код', + 'UPC': 'UPC-код', + 'Track total': 'Кількість треків в альбомі', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Текст пісні', + 'Genre': 'Жанр', + 'Contributors': 'Учасники', + 'Album art': 'Обкладинка', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer недоступний у вашій країні, ReFreezer може працювати некоректно. Будь ласка, використовуйте VPN', + 'Deezer is unavailable': 'Deezer недоступний', + 'Continue': 'Продовжити', + 'Email Login': 'Email Логін', + 'Email': 'Email', + 'Missing email or password!': 'Неправильний email чи пароль!', + 'Error logging in using email, please check your credentials.\nError:': + 'Помилка входу через email. Перевірте свої облікові дані.\nПомилка:', + 'Error logging in!': 'Помилка входу!', + 'Change display mode': 'Змінити режим відображення', + 'Enable high refresh rates': 'Увімкнути високу частоту оновлення', + 'Display mode': 'Режим відображення', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Імпортувати плейлисти Spotify до 100 треків без входу в систему.', + 'Download imported tracks': 'Завантажити імпортовані треки', + 'Start import': 'Почати імпорт', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Імпортувати будь-який плейлист Spotify, імпортувати з власної бібліотеки Spotify. Потрібен безкоштовний обліковий запис.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Для отримання цього імпортера потрібен Spotify Client ID та Client Secret. Для їх отримання:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + 'Перейдіть за посиланням: developer.spotify.com/dashboard і створіть програму.', + 'Open in Browser': 'Відкрити в браузері', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + 'У щойно створеній програмі перейдіть у налаштування та встановіть Redirect URL на: ', + 'Copy the Redirect URL': 'Скопіювати Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client secret', + 'Authorize': 'Авторизувати', + 'Logged in as: ': 'Авторизовано як: ', + 'Import playlists by URL': 'Імпортувати плейлисти за URL', + 'URL': 'URL', + 'Options': 'Параметри', + 'Invalid/Unsupported URL': 'Невірна/Непідтримувана URL-адреса', + 'Please wait...': 'Будь-ласка, зечекайте...', + 'Login using email': 'Увійти за допомогою email', + 'Track removed from offline!': 'Трек вилучено з офлайн!', + 'Removed album from offline!': 'Альбом вилучено з офлайн!', + 'Playlist removed from offline!': 'Плейлист вилучено з офлайн!', + 'Repeat': 'Повторення', + 'Repeat one': 'Повторити один', + 'Repeat off': 'Вимкнути повторення', + 'Love': 'Вподобано', + 'Unlove': 'Скасувати вподобання', + 'Dislike': 'Не вподобано', + 'Close': 'Закрити', + 'Sort playlist': 'Сортувати список', + 'Sort ascending': 'За збільшенням', + 'Sort descending': 'За зменшенням', + 'Stop': 'Зупинити', + 'Start': 'Почати', + 'Clear all': 'Очистити все', + 'Play previous': 'Відтворити попередній', + 'Play': 'Відтворити', + 'Pause': 'Призупинити', + 'Remove': 'Вилучити', + 'Seekbar': 'Панель перемотування', + 'Singles': 'Сингли', + 'Featured': 'Рекомендоване', + 'Fans': 'Шанувальники', + 'Duration': 'Тривалість', + 'Sort': 'Сортування', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Можливо, термін дії ARL минув, спробуйте вийти з обліковки та повернутися в систему за допомогою нового ARL або браузера.' + }, + 'ur_pk': { + 'Home': 'ہوم', + 'Search': 'تلاش کریں', + 'Library': 'لائبریری', + "Offline mode, can't play flow or smart track lists.": + 'آفلائن موڈ ، فلو اور گانوں کی لسٹ نہیں چل سکتی۔', + 'Added to library': 'لائبریری میں شامل کر دیا گیا', + 'Download': 'ڈاؤن لوڈ', + 'Disk': 'ڈسک', + 'Offline': 'آف لائن', + 'Top Tracks': 'بہترین گانے', + 'Show more tracks': 'اور گانے دکھائیں', + 'Top': 'بہترین', + 'Top Albums': 'بہترین البمز', + 'Show all albums': 'تمام البمز دکھائیں', + 'Discography': 'Discography', + 'Default': 'Default', + 'Reverse': 'Reverse', + 'Alphabetic': 'Alphabetic', + 'Artist': 'Artist', + 'Post processing...': 'Post processing...', + 'Done': 'Done', + 'Delete': 'Delete', + 'Are you sure you want to delete this download?': + 'Are you sure you want to delete this download?', + 'Cancel': 'Cancel', + 'Downloads': 'Downloads', + 'Clear queue': 'Clear queue', + "This won't delete currently downloading item": + "This won't delete currently downloading item", + 'Are you sure you want to delete all queued downloads?': + 'Are you sure you want to delete all queued downloads?', + 'Clear downloads history': 'Clear downloads history', + 'WARNING: This will only clear non-offline (external downloads)': + 'WARNING: This will only clear non-offline (external downloads)', + 'Please check your connection and try again later...': + 'Please check your connection and try again later...', + 'Show more': 'Show more', + 'Importer': 'Importer', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Currently supporting only Spotify, with 100 tracks limit', + 'Due to API limitations': 'Due to API limitations', + 'Enter your playlist link below': 'Enter your playlist link below', + 'Error loading URL!': 'Error loading URL!', + 'Convert': 'Convert', + 'Download only': 'Download only', + 'Downloading is currently stopped, click here to resume.': + 'Downloading is currently stopped, click here to resume.', + 'Tracks': 'Tracks', + 'Albums': 'Albums', + 'Artists': 'Artists', + 'Playlists': 'Playlists', + 'Import': 'Import', + 'Import playlists from Spotify': 'Import playlists from Spotify', + 'Statistics': 'Statistics', + 'Offline tracks': 'Offline tracks', + 'Offline albums': 'Offline albums', + 'Offline playlists': 'Offline playlists', + 'Offline size': 'Offline size', + 'Free space': 'Free space', + 'Loved tracks': 'Loved tracks', + 'Favorites': 'Favorites', + 'All offline tracks': 'All offline tracks', + 'Create new playlist': 'Create new playlist', + 'Cannot create playlists in offline mode': + 'Cannot create playlists in offline mode', + 'Error': 'Error', + 'Error logging in! Please check your token and internet connection and try again.': + 'Error logging in! Please check your token and internet connection and try again.', + 'Dismiss': 'Dismiss', + 'Welcome to': 'Welcome to', + 'Please login using your Deezer account.': + 'Please login using your Deezer account.', + 'Login using browser': 'Login using browser', + 'Login using token': 'Login using token', + 'Enter ARL': 'Enter ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Save', + "If you don't have account, you can register on deezer.com for free.": + "If you don't have account, you can register on deezer.com for free.", + 'Open in browser': 'Open in browser', + "By using this app, you don't agree with the Deezer ToS": + "By using this app, you don't agree with the Deezer ToS", + 'Play next': 'Play next', + 'Add to queue': 'Add to queue', + 'Add track to favorites': 'Add track to favorites', + 'Add to playlist': 'Add to playlist', + 'Select playlist': 'Select playlist', + 'Track added to': 'Track added to', + 'Remove from playlist': 'Remove from playlist', + 'Track removed from': 'Track removed from', + 'Remove favorite': 'Remove favorite', + 'Track removed from library': 'Track removed from library', + 'Go to': 'Go to', + 'Make offline': 'Make offline', + 'Add to library': 'Add to library', + 'Remove album': 'Remove album', + 'Album removed': 'Album removed', + 'Remove from favorites': 'Remove from favorites', + 'Artist removed from library': 'Artist removed from library', + 'Add to favorites': 'Add to favorites', + 'Remove from library': 'Remove from library', + 'Add playlist to library': 'Add playlist to library', + 'Added playlist to library': 'Added playlist to library', + 'Make playlist offline': 'Make playlist offline', + 'Download playlist': 'Download playlist', + 'Create playlist': 'Create playlist', + 'Title': 'Title', + 'Description': 'Description', + 'Private': 'Private', + 'Collaborative': 'Collaborative', + 'Create': 'Create', + 'Playlist created!': 'Playlist created!', + 'Playing from:': 'Playing from:', + 'Queue': 'Queue', + 'Offline search': 'Offline search', + 'Search Results': 'Search Results', + 'No results!': 'No results!', + 'Show all tracks': 'Show all tracks', + 'Show all playlists': 'Show all playlists', + 'Settings': 'Settings', + 'General': 'General', + 'Appearance': 'Appearance', + 'Quality': 'Quality', + 'Deezer': 'Deezer', + 'Theme': 'Theme', + 'Currently': 'Currently', + 'Select theme': 'Select theme', + 'Dark': 'Dark', + 'Black (AMOLED)': 'Black (AMOLED)', + 'Deezer (Dark)': 'Deezer (Dark)', + 'Primary color': 'Primary color', + 'Selected color': 'Selected color', + 'Use album art primary color': 'Use album art primary color', + 'Warning: might be buggy': 'Warning: might be buggy', + 'Mobile streaming': 'Mobile streaming', + 'Wifi streaming': 'Wifi streaming', + 'External downloads': 'External downloads', + 'Content language': 'Content language', + 'Not app language, used in headers. Now': + 'Not app language, used in headers. Now', + 'Select language': 'Select language', + 'Content country': 'Content country', + 'Country used in headers. Now': 'Country used in headers. Now', + 'Log tracks': 'Log tracks', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Send track listen logs to Deezer, enable it for features like Flow to work properly', + 'Offline mode': 'Offline mode', + 'Will be overwritten on start.': 'Will be overwritten on start.', + 'Error logging in, check your internet connections.': + 'Error logging in, check your internet connections.', + 'Logging in...': 'Logging in...', + 'Download path': 'Download path', + 'Downloads naming': 'Downloads naming', + 'Downloaded tracks filename': 'Downloaded tracks filename', + 'Valid variables are': 'Valid variables are', + 'Reset': 'Reset', + '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 disks', + 'Overwrite already downloaded files': 'Overwrite already downloaded files', + 'Copy ARL': 'Copy ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copy userToken/ARL Cookie for use in other apps.', + 'Copied': 'Copied', + 'Log out': 'Log out', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Due to plugin incompatibility, login using browser is unavailable without restart.', + '(ARL ONLY) Continue': '(ARL ONLY) Continue', + 'Log out & Exit': 'Log out & Exit', + 'Pick-a-Path': 'Pick-a-Path', + 'Select storage': 'Select storage', + 'Go up': 'Go up', + 'Permission denied': 'Permission denied', + 'Language': 'Language', + 'Language changed, please restart ReFreezer to apply!': + 'Language changed, please restart ReFreezer to apply!', + 'Importing...': 'Importing...', + '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.', + 'Storage permission denied!': 'Storage permission denied!', + 'Failed': 'Failed', + 'Queued': 'Queued', + 'External': 'Storage', + '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': 'Concurrent downloads', + '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!', + 'Save cover file for every track': 'Save cover file for every track', + 'Download Log': 'Download Log', + 'Repository': 'Repository', + 'Source code, report issues there.': 'Source code, report issues there.', + 'Use system theme': 'Use system theme', + 'Light': 'Light', + 'Popularity': 'Popularity', + 'User': 'User', + 'Track count': 'Track count', + "If you want to use custom directory naming - use '/' as directory separator.": + "If you want to use custom directory naming - use '/' as directory separator.", + 'Share': 'Share', + 'Save album cover': 'Save album cover', + 'Warning': 'Warning', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Using too many concurrent downloads on older/weaker devices might cause crashes!', + 'Create .nomedia files': 'Create .nomedia files', + 'To prevent gallery being filled with album art': + 'To prevent gallery being filled with album art', + 'Sleep timer': 'Sleep timer', + 'Minutes:': 'Minutes:', + 'Hours:': 'Hours:', + 'Cancel current timer': 'Cancel current timer', + 'Current timer ends at': 'Current timer ends at', + 'Smart track list': 'Smart track list', + 'Shuffle': 'Shuffle', + 'Library shuffle': 'Library shuffle', + 'Ignore interruptions': 'Ignore interruptions', + 'Requires app restart to apply!': 'Requires app restart to apply!', + 'Ask before downloading': 'Ask before downloading', + 'Search history': 'Search history', + 'Clear search history': 'Clear search history', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Login to enable scrobbling.', + 'Login to LastFM': 'Login to LastFM', + 'Username': 'Username', + 'Password': 'Password', + 'Login': 'Login', + 'Authorization error!': 'Authorization error!', + 'Logged out!': 'Logged out!', + 'Lyrics': 'Lyrics', + 'Player gradient background': 'Player gradient background', + 'Updates': 'Updates', + 'You are running latest version!': 'You are running latest version!', + 'New update available!': 'New update available!', + 'Current version: ': 'Current version: ', + 'Unsupported platform!': 'Unsupported platform!', + 'Freezer Updates': 'Freezer Updates', + 'Update to latest version in the settings.': + 'Update to latest version in the settings.', + 'Release date': 'Release date', + 'Shows': 'Shows', + 'Charts': 'Charts', + 'Browse': 'Browse', + 'Quick access': 'Quick access', + 'Play mix': 'Play mix', + 'Share show': 'Share show', + 'Date added': 'Date added', + 'Discord': 'Discord', + 'Official Discord server': 'Official Discord server', + 'Restart of app is required to properly log out!': + 'Restart of app is required to properly log out!', + 'Artist separator': 'Artist separator', + 'Singleton naming': 'Singleton naming', + 'Keep the screen on': 'Keep the screen on', + 'Wakelock enabled!': 'Wakelock enabled!', + 'Wakelock disabled!': 'Wakelock disabled!', + 'Show all shows': 'Show all shows', + 'Episodes': 'Episodes', + 'Show all episodes': 'Show all episodes', + 'Album cover resolution': 'Album cover resolution', + "WARNING: Resolutions above 1200 aren't officially supported": + "WARNING: Resolutions above 1200 aren't officially supported", + 'Album removed from library!': 'Album removed from library!', + 'Remove offline': 'Remove offline', + 'Playlist removed from library!': 'Playlist removed from library!', + 'Blur player background': 'Blur player background', + 'Might have impact on performance': 'Might have impact on performance', + 'Font': 'Font', + 'Select font': 'Select font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!", + 'Enable equalizer': 'Enable equalizer', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Might enable some equalizer apps to work. Requires restart of Freezer', + 'Visualizer': 'Visualizer', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Track number', + 'Disc number': 'Disc number', + 'Album artist': 'Album artist', + 'Date/Year': 'Date/Year', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Track total', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Unsynchronized lyrics', + 'Genre': 'Genre', + 'Contributors': 'Contributors', + 'Album art': 'Album art', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.' + }, + 'vi_vi': { + 'Home': 'Trang chủ', + 'Search': 'Tìm kiếm', + 'Library': 'Thư viện', + "Offline mode, can't play flow or smart track lists.": + 'Đang ở chế độ ngoại tuyến, không thể phát các danh sách nhạc được tự động tạo theo sở thích.', + 'Added to library': 'Đã thêm vào Thư viện', + 'Download': 'Tải xuống', + 'Disk': 'Đĩa', + 'Offline': 'Ngoại tuyến', + 'Top Tracks': 'Bài hát hàng đầu', + 'Show more tracks': 'Hiện thêm bài hát', + 'Top': 'Hàng đầu', + 'Top Albums': 'Album hàng đầu', + 'Show all albums': 'Hiện tất cả album', + 'Discography': 'Danh sách bài hát của nghệ sĩ', + 'Default': 'Mặc định', + 'Reverse': 'Đảo ngược', + 'Alphabetic': 'Theo thứ tự chữ cái', + 'Artist': 'Nghệ sĩ', + 'Post processing...': 'Hậu xử lý...', + 'Done': 'Hoàn tất', + 'Delete': 'Xóa', + 'Are you sure you want to delete this download?': + 'Bạn có chắc muốn xóa bản tải xuống này?', + 'Cancel': 'Hủy', + 'Downloads': 'Danh sách tải', + 'Clear queue': 'Xóa hàng chờ', + "This won't delete currently downloading item": + 'Không thể xóa mục đang tải', + 'Are you sure you want to delete all queued downloads?': + 'Bạn có chắc muốn xóa toàn bộ hàng chờ?', + 'Clear downloads history': 'Xóa lịch sử tải xuống', + 'WARNING: This will only clear non-offline (external downloads)': + 'CẢNH BÁO: Chỉ có nội dung không ngoại tuyến mới bị xóa (tải xuống bên ngoài)', + 'Please check your connection and try again later...': + 'Vui lòng kiểm tra kết nối internet của bạn và thử lại sau...', + 'Show more': 'Hiển thị thêm', + 'Importer': 'Công cụ nhập', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Hiện chỉ đang hỗ trợ nhập danh sách phát từ Spotify, tối đa 100 bài hát', + 'Due to API limitations': 'Do các hạn chế từ API', + 'Enter your playlist link below': + 'Nhập liên kết tới danh sách phát của bạn', + 'Error loading URL!': 'Xử lí liên kết gặp lỗi!', + 'Convert': 'Chuyển đổi', + 'Download only': 'Chỉ tải xuống', + 'Downloading is currently stopped, click here to resume.': + 'Đang tạm ngưng tải xuống, nhấn vào đây để tiếp tục.', + 'Tracks': 'Bài hát', + 'Albums': 'Album', + 'Artists': 'Nghệ sĩ', + 'Playlists': 'Danh sách phát', + 'Import': 'Nhập dữ liệu', + 'Import playlists from Spotify': 'Nhập danh sách phát từ Spotify', + 'Statistics': 'Thống kê', + 'Offline tracks': 'Bài hát ngoại tuyến', + 'Offline albums': 'Album ngoại tuyến', + 'Offline playlists': 'Danh sách phát ngoại tuyến', + 'Offline size': 'Dung lượng nhạc ngoại tuyến', + 'Free space': 'Dung lượng còn trống', + 'Loved tracks': 'Bài hát được ưa thích', + 'Favorites': 'Ưa thích', + 'All offline tracks': 'Tất cả bài hát ngoại tuyến', + 'Create new playlist': 'Tạo danh sách phát mới', + 'Cannot create playlists in offline mode': + 'Không thể tạo danh sách phát mới khi đang ngoại tuyến', + 'Error': 'Lỗi', + 'Error logging in! Please check your token and internet connection and try again.': + 'Đăng nhập lỗi! Xin kiểm tra lại token đăng nhập và đường truyền mạng và thử lại.', + 'Dismiss': 'Đóng', + 'Welcome to': 'Chào mừng đến với', + 'Please login using your Deezer account.': + 'Xin vui lòng đăng nhập bằng tài khoản Deezer của bạn.', + 'Login using browser': 'Đăng nhập bằng trình duyệt', + 'Login using token': 'Đăng nhập bằng mã token', + 'Enter ARL': 'Nhập mã token (ARL)', + 'Token (ARL)': 'Mã token (ARL)', + 'Save': 'Lưu', + "If you don't have account, you can register on deezer.com for free.": + 'Nếu bạn không có tài khoản, bạn có thể đăng kí miễn phí tại deezer.com', + 'Open in browser': 'Mở trong trình duyệt', + "By using this app, you don't agree with the Deezer ToS": + 'Với việc sử dụng ứng dụng này, bạn đã từ chối các điều khoản dịch vụ của deezer', + 'Play next': 'Phát kế tiếp', + 'Add to queue': 'Thêm vào hàng chờ', + 'Add track to favorites': 'Thêm vào mục ưa thích', + 'Add to playlist': 'Thêm vào danh sách phát', + 'Select playlist': 'Chọn danh sách phát', + 'Track added to': 'Bài hát đã thêm vào', + 'Remove from playlist': 'Xóa khỏi danh sách phát', + 'Track removed from': 'Bài hát đã xoá khỏi danh sách', + 'Remove favorite': 'Xoá khỏi mục ưa thích', + 'Track removed from library': 'Bài hát đã được xóa khỏi danh sách phát', + 'Go to': 'Đi đến', + 'Make offline': 'Chuyển sang thành ngoại tuyến', + 'Add to library': 'Thêm vào thư viện', + 'Remove album': 'Xóa album', + 'Album removed': 'Album đã xóa', + 'Remove from favorites': 'Xóa khỏi mục ưa thích', + 'Artist removed from library': 'Nghệ sĩ đã được xóa khỏi thư viện', + 'Add to favorites': 'Thêm vào mục ưa thích', + 'Remove from library': 'Xoá khỏi Thư Viện', + 'Add playlist to library': 'Thêm danh sách phát vào thư viện', + 'Added playlist to library': 'Danh sách phát đã được thêm vào thư viện', + 'Make playlist offline': 'Chuyển danh sách phát thành ngoại tuyến', + 'Download playlist': 'Tải xuống danh sách phát', + 'Create playlist': 'Tạo danh sách phát', + 'Title': 'Tiêu đề', + 'Description': 'Mô tả', + 'Private': 'Riêng tư', + 'Collaborative': 'Hợp tác', + 'Create': 'Tạo', + 'Playlist created!': 'Đã tạo danh sách phát!', + 'Playing from:': 'Phát từ:', + 'Queue': 'Hàng chờ', + 'Offline search': 'Tìm kiếm ngoại tuyến', + 'Search Results': 'Kết quả tìm kiếm', + 'No results!': 'Không có kết quả!', + 'Show all tracks': 'Hiện tất cả bài hát', + 'Show all playlists': 'Hiện tất cả danh sách phát', + 'Settings': 'Cài đặt', + 'General': 'Cài đặt chung', + 'Appearance': 'Giao diện', + 'Quality': 'Chất lượng', + 'Deezer': 'Deezer', + 'Theme': 'Chủ đề', + 'Currently': 'Hiện tại', + 'Select theme': 'Chọn chủ đề', + 'Dark': 'Tối', + 'Black (AMOLED)': 'Đen (AMOLED)', + 'Deezer (Dark)': 'Deezer (tối)', + 'Primary color': 'Màu chủ đạo', + 'Selected color': 'Màu đã chọn', + 'Use album art primary color': 'Sử dụng màu chủ đạo của album', + 'Warning: might be buggy': 'Cảnh báo: có thể gặp lỗi', + 'Mobile streaming': 'Thông qua mạng điện thoại', + 'Wifi streaming': 'Thông qua mạng wifi', + 'External downloads': 'Tải xuống để lưu trữ', + 'Content language': 'Ngôn ngữ nội dung', + 'Not app language, used in headers. Now': + 'Ngôn ngữ được sử dụng trong tiêu đề, không phải của ứng dụng. Hiện tại', + 'Select language': 'Chọn ngôn ngữ', + 'Content country': 'Quốc gia của nội dung', + 'Country used in headers. Now': 'Quốc gia hiện tại', + 'Log tracks': 'Lưu ghi chép về lịch sử nghe', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Gửi các ghi chép này tới Deezer, nên kích hoạt để tính năng phát nhạc thông minh hoạt động hiệu quả', + 'Offline mode': 'Chế độ ngoại tuyến', + 'Will be overwritten on start.': 'Sẽ bị ghi đè khi khởi động.', + 'Error logging in, check your internet connections.': + 'Lỗi đăng nhập, xin kiểm tra đường truyền.', + 'Logging in...': 'Đang đăng nhập...', + 'Download path': 'Vị trí thư mục tải xuống', + 'Downloads naming': 'Phong cách đặt tên', + 'Downloaded tracks filename': 'Tên file đã tải xuống', + 'Valid variables are': 'Các biến thể được cho phép', + 'Reset': 'Thiết lập lại', + 'Clear': 'Xóa', + 'Create folders for artist': 'Tạo thư mục theo tên nghệ sĩ', + 'Create folders for albums': 'Tạo thư mục theo tên album', + 'Separate albums by discs': 'Tách album theo đĩa đơn', + 'Overwrite already downloaded files': + 'Ghi đè lên file trùng tên đã tải xuống', + 'Copy ARL': 'Sao chép ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Sao chép mã Token/ARL để dùng cho ứng dụng khác.', + 'Copied': 'Đã sao chép', + 'Log out': 'Đăng xuất', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Công cụ plugin không tương thích, cần khởi động lại để có thể đăng nhập bằng trình duyệt.', + '(ARL ONLY) Continue': '(Chỉ dùng ARL) Tiếp tục', + 'Log out & Exit': 'Đăng xuất và thoát', + 'Pick-a-Path': 'Chọn đường dẫn', + 'Select storage': 'Chọn bộ nhớ', + 'Go up': 'Về trước', + 'Permission denied': 'Quyền truy cập bị từ chối', + 'Language': 'Ngôn ngữ', + 'Language changed, please restart ReFreezer to apply!': + 'Đã thay đổi ngôn ngữ, xin vui lòng khởi động lại Freezer!', + 'Importing...': 'Đang nhập...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Bài hát không có sẵn trên Deezer!', + 'Failed to download track! Please restart.': + 'Tải bài hát thất bại! Xin khởi động lại.', + 'Storage permission denied!': 'Quyền truy cập bộ nhớ bị từ chối!', + 'Failed': 'Thất bại', + 'Queued': 'Đã xếp hàng chờ', + 'External': 'Bộ nhớ', + 'Restart failed downloads': 'Tải lại tập tin tải lỗi', + 'Clear failed': 'Xoá tác vụ lỗi', + 'Download Settings': 'Cài đặt tải xuống', + 'Create folder for playlist': 'Tạo thư mục theo danh sách phát', + 'Download .LRC lyrics': 'Tải lời bài hát đuôi .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Chưa được đặt', + 'Search or paste URL': 'Tìm kiếm hoặc nhập URL', + 'History': 'Lịch sử', + 'Download threads': 'Tải xuống đồng thời', + 'Lyrics unavailable, empty or failed to load!': + 'Lời bài hát không có sẵn, trống hoặc tải lỗi!', + 'About': 'Giới thiệu', + 'Telegram Channel': 'Kênh Telegram', + 'To get latest releases': 'Để có cập nhật mới nhất', + 'Official chat': 'Kênh chat chính thức', + 'Telegram Group': 'Nhóm Telegram', + 'Huge thanks to all the contributors! <3': + 'Xin gửi lời cảm ơn tới tất cả những cá nhân đã đóng góp! <3', + 'Edit playlist': 'Chỉnh sửa danh sách phát', + 'Update': 'Cập nhật', + 'Playlist updated!': 'Đã cập nhật danh sách phát!', + 'Downloads added!': 'Đã thêm vào danh sách tải!', + 'Save cover file for every track': 'Lưu ảnh bìa bài hát thành file riêng', + 'Download Log': 'Nhật kí tải xuống', + 'Repository': 'Kho dữ liệu', + 'Source code, report issues there.': 'Mã nguồn mỡ, báo lỗi tại đây.', + 'Use system theme': 'Sử dụng chủ đề của hệ thống', + 'Light': 'Sáng', + 'Popularity': 'Phổ biến', + 'User': 'Người dùng', + 'Track count': 'Số lượng bài hát', + "If you want to use custom directory naming - use '/' as directory separator.": + "Nếu bạn muốn tạo đường dẫn lưu trữ cá nhân, hãy sử dụng '/' làm dấu phân tách.", + 'Share': 'Chia sẻ', + 'Save album cover': 'Lưu ảnh bìa album', + 'Warning': 'Cảnh báo', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Tải xuống cùng lúc quá nhiều có thể gây treo ứng dụng trên máy cấu hình thấp!', + 'Create .nomedia files': 'Tạo file .nomedia', + 'To prevent gallery being filled with album art': + 'Để ngăn việc các ứng dụng thư viện nhận diện những ảnh bìa này', + 'Sleep timer': 'Hẹn giờ tắt', + 'Minutes:': 'Phút:', + 'Hours:': 'Giờ:', + 'Cancel current timer': 'Hủy hẹn giờ hiện tại', + 'Current timer ends at': 'Hẹn giờ hiện tại kết thúc ở', + 'Smart track list': 'Danh sánh bài hát thông minh', + 'Shuffle': 'Trộn', + 'Library shuffle': 'Trộn thư viện nhạc', + 'Ignore interruptions': 'Bỏ qua gián đoạn', + 'Requires app restart to apply!': 'Khởi động lại ứng dụng để áp dụng!', + 'Ask before downloading': 'Hỏi trước khi tải', + 'Search history': 'Lịch sử tìm kiếm', + 'Clear search history': 'Xóa lịch sử tìm kiếm', + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': + 'Đăng nhập để kích hoạt hệ thống đề xuất âm nhạc.', + 'Login to LastFM': 'Đăng nhập tài khoản LastFM', + 'Username': 'Tên đăng nhập', + 'Password': 'Mật khẩu', + 'Login': 'Đăng nhập', + 'Authorization error!': 'Lỗi xác thực!', + 'Logged out!': 'Đã đăng xuất!', + 'Lyrics': 'Lời bài hát', + 'Player gradient background': 'Trình phát nền gradient', + 'Updates': 'Cập Nhật', + 'You are running latest version!': 'Bạn đang sử dụng phiên bản mới nhất!', + 'New update available!': 'Có bản cập nhật mới!', + 'Current version: ': 'Phiên bản hiện tại: ', + 'Unsupported platform!': 'Nền tảng không được hỗ trợ!', + 'Freezer Updates': 'Cập nhật Freezer', + 'Update to latest version in the settings.': + 'Cập nhật phiên bản mới nhất trong cài đặt.', + 'Release date': 'Ngày phát hành', + 'Shows': 'Chương trình', + 'Charts': 'Bảng xếp hạng', + 'Browse': 'Duyệt', + 'Quick access': 'Truy cập nhanh', + 'Play mix': 'Phát hỗn hợp', + 'Share show': 'Chia sẻ chương trình', + 'Date added': 'Ngày thêm vào', + 'Discord': 'Discord', + 'Official Discord server': 'Máy chủ Discord chính thức', + 'Restart of app is required to properly log out!': + 'Cần khởi động lại ứng dụng để đăng xuất hoàn toàn!', + 'Artist separator': 'Dấu phân tách nghệ sĩ', + 'Singleton naming': 'Tên bài hát riêng lẻ', + 'Keep the screen on': 'Giữ màn hình luôn bật', + 'Wakelock enabled!': 'Đã bật Wakelock!', + 'Wakelock disabled!': 'Đã tắt Wakelock!', + 'Show all shows': 'Hiển thị tất cả chương trình', + 'Episodes': 'Tập', + 'Show all episodes': 'Hiển thị tất cả tập', + 'Album cover resolution': 'Độ phân giải ảnh bìa album', + "WARNING: Resolutions above 1200 aren't officially supported": + 'CẢNH BÁO: Độ phân giải trên 1200 không được hỗ trợ chính thức', + 'Album removed from library!': 'Album đã được xóa khỏi thư viện!', + 'Remove offline': 'Xoá bản ngoại tuyến', + 'Playlist removed from library!': + 'Danh sách phát đã được xóa khỏi thư viện!', + 'Blur player background': 'Làm mờ trình phát ở nền', + 'Might have impact on performance': 'Có thễ sẽ ảnh hưởng đến hiệu suất', + 'Font': 'Font chữ', + 'Select font': 'Chọn font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + 'Ứng dụng này không hỗ trợ nhiều phông chữ, bố cục và ký tự có thể sẽ lỗi và bị tràn. Tùy bạn quyết định!', + 'Enable equalizer': 'Bật bộ chỉnh âm', + 'Might enable some equalizer apps to work. Requires restart of Freezer': + 'Có thể cho phép sử dụng ứng dụng chỉnh âm. Yêu cầu khởi động lại Freezer', + 'Visualizer': 'Trình trực quan', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Hiển thị trình trực quan trên trang lời bài hát. CẢNH BÁO: Yêu cầu quyền sử dụng micrô!', + 'Tags': 'Thẻ', + 'Album': 'Album', + 'Track number': 'Số thứ tự bài hát', + 'Disc number': 'Số thứ tự đĩa', + 'Album artist': 'Album nghệ sĩ', + 'Date/Year': 'Ngày/Năm', + 'Label': 'Nhãn hiệu', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Tổng số bài hát', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Lời bài hát', + 'Genre': 'Thể loại', + 'Contributors': 'Người đóng góp', + 'Album art': 'Ảnh album', + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer không khả dụng ở quốc gia của bạn, ReFreezer có thể sẽ không hoạt động như bình thường. Vui lòng sử dụng VPN', + 'Deezer is unavailable': 'Deezer không khả dụng', + 'Continue': 'Tiếp tục', + 'Email Login': 'Đăng nhập qua email', + 'Email': 'Email', + 'Missing email or password!': 'Thiếu email hoặc mật khẩu!', + 'Error logging in using email, please check your credentials.\nError:': + 'Lỗi khi đăng nhập bằng email, vui lòng kiểm tra lại thông tin.\nLỗi:', + 'Error logging in!': 'Lỗi đăng nhập!', + 'Change display mode': 'Thay đổi chế độ hiển thị', + 'Enable high refresh rates': 'Bật tốc độ làm mới cao', + 'Display mode': 'Chế độ Hiển thị', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Nhập danh sách phát Spotify lên đến 100 bản nhạc mà không cần đăng nhập.', + 'Download imported tracks': 'Tải các bài hát đã được nhập', + 'Start import': 'Bắt đầu nhập', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Nhập bất kỳ danh sách phát Spotify nào, nhập từ thư viện Spotify của bạn. Yêu cầu tài khoản Spotify miễn phí.', + 'Spotify Importer v2': 'Công cụ nhập Spotify v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'Công cụ nhập yêu cầu ID Spotify và Client Secret. Để có được chúng:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Truy cập: developer.spotify.com/dashboard và chọn tạo ứng dụng (create an app).', + 'Open in Browser': 'Mở trong trình duyệt', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. Trong ứng dụng vừa tạo, tới phần cài đặt (edit setting) và đặt chuyển hướng URL (Redirect URL) thành: ', + 'Copy the Redirect URL': 'Chép đường dẫn chuyển hướng URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Cho phép', + 'Logged in as: ': 'Đăng nhập với: ', + 'Import playlists by URL': 'Nhập danh sách phát qua URL', + 'URL': 'URL', + 'Options': 'Tùy chọn', + 'Invalid/Unsupported URL': 'URL không hợp lệ/không hỗ trợ', + 'Please wait...': 'Vui lòng chờ...', + 'Login using email': 'Đăng nhập với Email', + 'Track removed from offline!': 'Bài hát đã được xóa khỏi ngoại tuyến!', + 'Removed album from offline!': 'Album đã được xóa khỏi ngoại tuyến!', + 'Playlist removed from offline!': + 'Danh sách phát đã được xóa khỏi ngoại tuyến!', + 'Repeat': 'Lặp lại', + 'Repeat one': 'Lặp lại một lần', + 'Repeat off': 'Tắt lặp lại', + 'Love': 'Yêu thích', + 'Unlove': 'Bỏ yêu thích', + 'Dislike': 'Không thích', + 'Close': 'Đóng', + 'Sort playlist': 'Lọc danh sách phát', + 'Sort ascending': 'Xếp tăng dần', + 'Sort descending': 'Xếp giảm dần', + 'Stop': 'Dừng', + 'Start': 'Bắt đầu', + 'Clear all': 'Xoá tất cả', + 'Play previous': 'Phát trước đó', + 'Play': 'Phát', + 'Pause': 'Tạm dừng', + 'Remove': 'Loại bỏ', + 'Seekbar': 'Seekbar', + 'Singles': 'Đĩa đơn', + 'Featured': 'Nổi bật', + 'Fans': 'Fans', + 'Duration': 'Thời lượng', + 'Sort': 'Sắp xếp', + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'ARL của bạn có thể đã hết hạn, hãy đăng xuất và đăng nhập lại bằng ARL mới hoặc qua trình duyệt.' + } +}; diff --git a/lib/languages/en_us.dart b/lib/languages/en_us.dart new file mode 100644 index 0000000..72058a3 --- /dev/null +++ b/lib/languages/en_us.dart @@ -0,0 +1,445 @@ +const language_en_us = { + 'en_us': { + 'Home': 'Home', + 'Search': 'Search', + 'Library': 'Library', + "Offline mode, can't play flow or smart track lists.": + "Offline mode, can't play flow or smart track lists.", + 'Added to library': 'Added to library', + 'Download': 'Download', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Top Tracks', + 'Show more tracks': 'Show more tracks', + 'Top': 'Top', + 'Top Albums': 'Top Albums', + 'Show all albums': 'Show all albums', + 'Discography': 'Discography', + 'Default': 'Default', + 'Reverse': 'Reverse', + 'Alphabetic': 'Alphabetic', + 'Artist': 'Artist', + 'Post processing...': 'Post processing...', + 'Done': 'Done', + 'Delete': 'Delete', + 'Are you sure you want to delete this download?': + 'Are you sure you want to delete this download?', + 'Cancel': 'Cancel', + 'Downloads': 'Downloads', + 'Clear queue': 'Clear queue', + "This won't delete currently downloading item": + "This won't delete currently downloading item", + 'Are you sure you want to delete all queued downloads?': + 'Are you sure you want to delete all queued downloads?', + 'Clear downloads history': 'Clear downloads history', + 'WARNING: This will only clear non-offline (external downloads)': + 'WARNING: This will only clear non-offline (external downloads)', + 'Please check your connection and try again later...': + 'Please check your connection and try again later...', + 'Show more': 'Show more', + 'Importer': 'Importer', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Currently supporting only Spotify, with 100 tracks limit', + 'Due to API limitations': 'Due to API limitations', + 'Enter your playlist link below': 'Enter your playlist link below', + 'Error loading URL!': 'Error loading URL!', + 'Convert': 'Convert', + 'Download only': 'Download only', + 'Downloading is currently stopped, click here to resume.': + 'Downloading is currently stopped, click here to resume.', + 'Tracks': 'Tracks', + 'Albums': 'Albums', + 'Artists': 'Artists', + 'Playlists': 'Playlists', + 'Import': 'Import', + 'Import playlists from Spotify': 'Import playlists from Spotify', + 'Statistics': 'Statistics', + 'Offline tracks': 'Offline tracks', + 'Offline albums': 'Offline albums', + 'Offline playlists': 'Offline playlists', + 'Offline size': 'Offline size', + 'Free space': 'Free space', + 'Loved tracks': 'Loved tracks', + 'Favorites': 'Favorites', + 'All offline tracks': 'All offline tracks', + 'Create new playlist': 'Create new playlist', + 'Cannot create playlists in offline mode': + 'Cannot create playlists in offline mode', + 'Error': 'Error', + 'Error logging in! Please check your token and internet connection and try again.': + 'Error logging in! Please check your token and internet connection and try again.', + 'Dismiss': 'Dismiss', + 'Welcome to': 'Welcome to', + 'Please login using your Deezer account.': + 'Please login using your Deezer account.', + 'Login using browser': 'Login using browser', + 'Login using token': 'Login using token', + 'Enter ARL': 'Enter ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Save', + "If you don't have account, you can register on deezer.com for free.": + "If you don't have account, you can register on deezer.com for free.", + 'Open in browser': 'Open in browser', + "By using this app, you don't agree with the Deezer ToS": + "By using this app, you don't agree with the Deezer ToS", + 'Play next': 'Play next', + 'Add to queue': 'Add to queue', + 'Add track to favorites': 'Add track to favorites', + 'Add to playlist': 'Add to playlist', + 'Select playlist': 'Select playlist', + 'Track added to': 'Track added to', + 'Remove from playlist': 'Remove from playlist', + 'Track removed from': 'Track removed from', + 'Remove favorite': 'Remove favorite', + 'Track removed from library': 'Track removed from library', + 'Go to': 'Go to', + 'Make offline': 'Make offline', + 'Add to library': 'Add to library', + 'Remove album': 'Remove album', + 'Album removed': 'Album removed', + 'Remove from favorites': 'Remove from favorites', + 'Artist removed from library': 'Artist removed from library', + 'Add to favorites': 'Add to favorites', + 'Remove from library': 'Remove from library', + 'Add playlist to library': 'Add playlist to library', + 'Added playlist to library': 'Added playlist to library', + 'Make playlist offline': 'Make playlist offline', + 'Download playlist': 'Download playlist', + 'Create playlist': 'Create playlist', + 'Title': 'Title', + 'Description': 'Description', + 'Private': 'Private', + 'Collaborative': 'Collaborative', + 'Create': 'Create', + 'Playlist created!': 'Playlist created!', + 'Playing from:': 'Playing from:', + 'Queue': 'Queue', + 'Offline search': 'Offline search', + 'Search Results': 'Search Results', + 'No results!': 'No results!', + 'Show all tracks': 'Show all tracks', + 'Show all playlists': 'Show all playlists', + 'Settings': 'Settings', + 'General': 'General', + 'Appearance': 'Appearance', + 'Quality': 'Quality', + 'Deezer': 'Deezer', + 'Theme': 'Theme', + 'Currently': 'Currently', + 'Select theme': 'Select theme', + 'Dark': 'Dark', + 'Black (AMOLED)': 'Black (AMOLED)', + 'Deezer (Dark)': 'Deezer (Dark)', + 'Primary color': 'Primary color', + 'Selected color': 'Selected color', + 'Use album art primary color': 'Use album art primary color', + 'Warning: might be buggy': 'Warning: might be buggy', + 'Mobile streaming': 'Mobile streaming', + 'Wifi streaming': 'Wifi streaming', + 'External downloads': 'External downloads', + 'Content language': 'Content language', + 'Not app language, used in headers. Now': + 'Not app language, used in headers. Now', + 'Select language': 'Select language', + 'Content country': 'Content country', + 'Country used in headers. Now': 'Country used in headers. Now', + 'Log tracks': 'Log tracks', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Send track listen logs to Deezer, enable it for features like Flow to work properly', + 'Offline mode': 'Offline mode', + 'Will be overwritten on start.': 'Will be overwritten on start.', + 'Error logging in, check your internet connections.': + 'Error logging in, check your internet connections.', + 'Logging in...': 'Logging in...', + 'Download path': 'Download path', + 'Downloads naming': 'Downloads naming', + 'Downloaded tracks filename': 'Downloaded tracks filename', + 'Valid variables are': 'Valid variables are', + 'Reset': 'Reset', + '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 disks', + 'Overwrite already downloaded files': 'Overwrite already downloaded files', + 'Copy ARL': 'Copy ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copy userToken/ARL Cookie for use in other apps.', + 'Copied': 'Copied', + 'Log out': 'Log out', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Due to plugin incompatibility, login using browser is unavailable without restart.', + '(ARL ONLY) Continue': '(ARL ONLY) Continue', + 'Log out & Exit': 'Log out & Exit', + 'Pick-a-Path': 'Pick-a-Path', + 'Select storage': 'Select storage', + 'Go up': 'Go up', + 'Permission denied': 'Permission denied', + 'Language': 'Language', + 'Language changed, please restart ReFreezer to apply!': + 'Language changed, please restart ReFreezer to apply!', + 'Importing...': 'Importing...', + '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.', + + //0.5.0 Strings: + 'Storage permission denied!': 'Storage permission denied!', + 'Failed': 'Failed', + 'Queued': 'Queued', + //Updated in 0.5.1 - used in context of download: + 'External': 'Storage', + //0.5.0 + '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', + //Updated 0.5.1 + 'Download threads': 'Concurrent downloads', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': + 'Lyrics unavailable, empty or failed to load!', + 'About': 'About', + 'Telegram Channel': 'Telegram Channel', + 'To get latest releases': 'To get latest releases', + 'Official chat': 'Official chat', + 'Telegram Group': 'Telegram Group', + 'Huge thanks to all the contributors! <3': + 'Huge thanks to all the contributors! <3', + 'Edit playlist': 'Edit playlist', + 'Update': 'Update', + 'Playlist updated!': 'Playlist updated!', + 'Downloads added!': 'Downloads added!', + + //0.5.1 Strings: + 'Save cover file for every track': 'Save cover file for every track', + 'Download Log': 'Download Log', + 'Repository': 'Repository', + 'Source code, report issues there.': 'Source code, report issues there.', + + //0.5.2 Strings: + 'Use system theme': 'Use system theme', + 'Light': 'Light', + + //0.5.3 Strings: + 'Popularity': 'Popularity', + 'User': 'User', + 'Track count': 'Track count', + "If you want to use custom directory naming - use '/' as directory separator.": + "If you want to use custom directory naming - use '/' as directory separator.", + + //0.5.4 Strings: + 'Share': 'Share', + 'Save album cover': 'Save album cover', + 'Warning': 'Warning', + 'Using too many concurrent downloads on older/weaker devices might cause crashes!': + 'Using too many concurrent downloads on older/weaker devices might cause crashes!', + + //0.5.6 Strings: + 'Create .nomedia files': 'Create .nomedia files', + 'To prevent gallery being filled with album art': + 'To prevent gallery being filled with album art', + + //0.5.7 Strings: + 'Sleep timer': 'Sleep timer', + 'Minutes:': 'Minutes:', + 'Hours:': 'Hours:', + 'Cancel current timer': 'Cancel current timer', + 'Current timer ends at': 'Current timer ends at', + + //0.5.8 Strings: + 'Smart track list': 'Smart track list', + + //0.6.0 Strings: + 'Shuffle': 'Shuffle', + 'Library shuffle': 'Library shuffle', + 'Ignore interruptions': 'Ignore interruptions', + 'Requires app restart to apply!': 'Requires app restart to apply!', + 'Ask before downloading': 'Ask before downloading', + + //0.6.1 Strings: + 'Search history': 'Search history', + 'Clear search history': 'Clear search history', + + //0.6.2 Strings: + 'LastFM': 'LastFM', + 'Login to enable scrobbling.': 'Login to enable scrobbling.', + 'Login to LastFM': 'Login to LastFM', + 'Username': 'Username', + 'Password': 'Password', + 'Login': 'Login', + 'Authorization error!': 'Authorization error!', + 'Logged out!': 'Logged out!', + 'Lyrics': 'Lyrics', + 'Player gradient background': 'Player gradient background', + + //0.6.3 Strings: + 'Updates': 'Updates', + 'You are running latest version!': 'You are running latest version!', + 'New update available!': 'New update available!', + 'Current version: ': 'Current version: ', + 'Unsupported platform!': 'Unsupported platform!', + 'ReFreezer Updates': 'ReFreezer Updates', + 'Update to latest version in the settings.': + 'Update to latest version in the settings.', + 'Release date': 'Release date', + + //0.6.4 Strings: + 'Shows': 'Shows', + 'Charts': 'Charts', + 'Browse': 'Browse', + 'Quick access': 'Quick access', + 'Play mix': 'Play mix', + 'Share show': 'Share show', + 'Date added': 'Date added', + 'Discord': 'Discord', + 'Official Discord server': 'Official Discord server', + + //0.6.6 + 'Restart of app is required to properly log out!': + 'Restart of app is required to properly log out!', + 'Artist separator': 'Artist separator', + 'Singleton naming': 'Standalone tracks filename', + + //0.6.7 + 'Keep the screen on': 'Keep the screen on', + 'Wakelock enabled!': 'Wakelock enabled!', + 'Wakelock disabled!': 'Wakelock disabled!', + 'Show all shows': 'Show all shows', + 'Episodes': 'Episodes', + 'Show all episodes': 'Show all episodes', + 'Album cover resolution': 'Album cover resolution', + "WARNING: Resolutions above 1200 aren't officially supported": + "WARNING: Resolutions above 1200 aren't officially supported", + + //0.6.8: + 'Album removed from library!': 'Album removed from library!', + 'Remove offline': 'Remove offline', + 'Playlist removed from library!': 'Playlist removed from library!', + + //0.6.9: + 'Blur player background': 'Blur player background', + 'Might have impact on performance': 'Might have impact on performance', + 'Font': 'Font', + 'Select font': 'Select font', + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!": + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!", + 'Enable equalizer': 'Enable equalizer', + 'Might enable some equalizer apps to work. Requires restart of ReFreezer': + 'Might enable some equalizer apps to work. Requires restart of ReFreezer', + 'Visualizer': 'Visualizer', + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!': + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!', + 'Tags': 'Tags', + 'Album': 'Album', + 'Track number': 'Track number', + 'Disc number': 'Disc number', + 'Album artist': 'Album artist', + 'Date/Year': 'Date/Year', + 'Label': 'Label', + 'ISRC': 'ISRC', + 'UPC': 'UPC', + 'Track total': 'Track total', + 'BPM': 'BPM', + 'Unsynchronized lyrics': 'Unsynchronized lyrics', + 'Genre': 'Genre', + 'Contributors': 'Contributors', + 'Album art': 'Album art', + + //0.6.10 + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN': + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN', + 'Deezer is unavailable': 'Deezer is unavailable', + 'Continue': 'Continue', + 'Email Login': 'Email Login', + 'Email': 'Email', + 'Missing email or password!': 'Missing email or password!', + 'Error logging in using email, please check your credentials.\nError:': + 'Error logging in using email, please check your credentials.\nError:', + 'Error logging in!': 'Error logging in!', + 'Change display mode': 'Change display mode', + 'Enable high refresh rates': 'Enable high refresh rates', + 'Display mode': 'Display mode', + 'Spotify v1': 'Spotify v1', + 'Import Spotify playlists up to 100 tracks without any login.': + 'Import Spotify playlists up to 100 tracks without any login.', + 'Download imported tracks': 'Download imported tracks', + 'Start import': 'Start import', + 'Spotify v2': 'Spotify v2', + 'Import any Spotify playlist, import from own Spotify library. Requires free account.': + 'Import any Spotify playlist, import from own Spotify library. Requires free account.', + 'Spotify Importer v2': 'Spotify Importer v2', + 'This importer requires Spotify Client ID and Client Secret. To obtain them:': + 'This importer requires Spotify Client ID and Client Secret. To obtain them:', + '1. Go to: developer.spotify.com/dashboard and create an app.': + '1. Go to: developer.spotify.com/dashboard and create an app.', + 'Open in Browser': 'Open in Browser', + '2. In the app you just created go to settings, and set the Redirect URL to: ': + '2. In the app you just created go to settings, and set the Redirect URL to: ', + 'Copy the Redirect URL': 'Copy the Redirect URL', + 'Client ID': 'Client ID', + 'Client Secret': 'Client Secret', + 'Authorize': 'Authorize', + 'Logged in as: ': 'Logged in as: ', + 'Import playlists by URL': 'Import playlists by URL', + 'URL': 'URL', + 'Options': 'Options', + 'Invalid/Unsupported URL': 'Invalid/Unsupported URL', + 'Please wait...': 'Please wait...', + 'Login using email': 'Login using email', + + //0.6.11, offline text OCD lol + 'Track removed from offline!': 'Track removed from offline!', + 'Removed album from offline!': 'Removed album from offline!', + 'Playlist removed from offline!': 'Playlist removed from offline!', + + //0.6.11 - a11y by dangou + 'Repeat': 'Repeat', + 'Repeat one': 'Repeat one', + 'Repeat off': 'Repeat off', + 'Love': 'Love', + 'Unlove': 'Unlove', + 'Dislike': 'Dislike', + 'Close': 'Close', + 'Sort playlist': 'Sort playlist', + 'Sort ascending': 'Sort ascending', + 'Sort descending': 'Sort descending', + 'Stop': 'Stop', + 'Start': 'Start', + 'Clear all': 'Clear all', + 'Play previous': 'Play previous', + 'Play': 'Play', + 'Pause': 'Pause', + 'Remove': 'Remove', + 'Seekbar': 'Seekbar', + 'Singles': 'Singles', + 'Featured': 'Featured', + 'Fans': 'Fans', + 'Duration': 'Duration', + 'Sort': 'Sort', + + //0.6.12 + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.': + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.', + + //0.7.05 + 'The original freezer development team': + 'The original freezer development team', + 'Donate': 'Donate', + 'You should rather support your favorite artists, instead of this app!': + 'You should rather support your favorite artists, instead of this app!', + 'No really, go support your favorite artists instead ;)': + 'No really, go support your favorite artists instead ;)', + 'Storage permission is required to download content.\nPlease open settings and grant storage permission to ReFreezer.': + 'Storage permission is required to download content.\nPlease open settings and grant storage permission to ReFreezer.', + 'Open system settings': 'Open system settings', + 'Application Log': 'Application Log', + 'Are you sure you want to log out?': 'Are you sure you want to log out?', + } +}; diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..c335d72 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,496 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; +import 'package:custom_navigator/custom_navigator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:i18n_extension/i18n_extension.dart'; +import 'package:logging/logging.dart'; +import 'package:move_to_background/move_to_background.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:quick_actions/quick_actions.dart'; +import 'package:refreezer/ui/restartable.dart'; +//import 'package:restart_app/restart_app.dart'; + +import 'api/cache.dart'; +import 'api/deezer.dart'; +import 'api/definitions.dart'; +import 'api/download.dart'; +import 'service/audio_service.dart'; +import 'service/service_locator.dart'; +import 'settings.dart'; +import 'translations.i18n.dart'; +import 'ui/home_screen.dart'; +import 'ui/library.dart'; +import 'ui/login_screen.dart'; +import 'ui/player_bar.dart'; +//import 'ui/updater.dart'; +import 'ui/search.dart'; +import 'utils/logging.dart'; +import 'utils/navigator_keys.dart'; + +late Function updateTheme; +late Function logOut; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await Permission.notification.isDenied.then((value) { + if (value) { + Permission.notification.request(); + } + }); + + await prepareRun(); + + runApp(const Restartable(child: ReFreezerApp())); +} + +Future prepareRun() async { + await initializeLogging(); + Logger.root.info('Starting ReFreezer App...'); + settings = await Settings().loadSettings(); + cache = await Cache.load(); +} + +class ReFreezerApp extends StatefulWidget { + const ReFreezerApp({super.key}); + + @override + _ReFreezerAppState createState() => _ReFreezerAppState(); +} + +class _ReFreezerAppState extends State { + @override + void initState() { + //Make update theme global + updateTheme = _updateTheme; + _updateTheme(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _updateTheme() { + setState(() { + settings.themeData; + }); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: settings.themeData.bottomAppBarTheme.color, + systemNavigationBarIconBrightness: + settings.isDark ? Brightness.light : Brightness.dark, + )); + } + + Locale? _locale() { + if ((settings.language?.split('_').length ?? 0) < 2) return null; + return Locale( + settings.language!.split('_')[0], settings.language!.split('_')[1]); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'ReFreezer', + shortcuts: { + ...WidgetsApp.defaultShortcuts, + LogicalKeySet(LogicalKeyboardKey.select): + const ActivateIntent(), // DPAD center key, for remote controls + }, + theme: settings.themeData, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: supportedLocales, + home: PopScope( + canPop: false, // Prevent full app exit + onPopInvoked: (bool didPop) async { + // When at least 1 layer inside a custom navigator screen, + // let the back button move back down the custom navigator stack + if (customNavigatorKey.currentState!.canPop()) { + await customNavigatorKey.currentState!.maybePop(); + return; + } + + // When on a root screen of the custom navigator, move app to background with back button + await MoveToBackground.moveTaskToBack(); + return; + }, + child: I18n( + initialLocale: _locale(), + child: const LoginMainWrapper(), + ), + ), + navigatorKey: mainNavigatorKey, + ); + } +} + +//Wrapper for login and main screen. +class LoginMainWrapper extends StatefulWidget { + const LoginMainWrapper({super.key}); + + @override + _LoginMainWrapperState createState() => _LoginMainWrapperState(); +} + +class _LoginMainWrapperState extends State { + @override + void initState() { + super.initState(); + //GetIt.I().start(); + //Load token on background + deezerAPI.arl = settings.arl; + settings.offlineMode = true; + deezerAPI.authorize().then((b) async { + if (b) setState(() => settings.offlineMode = false); + }); + //Global logOut function + logOut = _logOut; + } + + Future _logOut() async { + try { + GetIt.I().stop(); + GetIt.I().updateQueue([]); + GetIt.I().removeSavedQueueFile(); + } catch (e, st) { + Logger.root.severe( + 'Error stopping and clearing audio service before logout', e, st); + } + await downloadManager.stop(); + await DownloadManager.platform.invokeMethod('kill'); + setState(() { + settings.arl = null; + settings.offlineMode = false; + deezerAPI = DeezerAPI(); + }); + await settings.save(); + await Cache.wipe(); + Restartable.restart(); + //Restart.restartApp(); + } + + @override + Widget build(BuildContext context) { + if (settings.arl == null) { + return LoginWidget( + callback: () => setState(() => {}), + ); + } + return const MainScreen(); + } +} + +class MainScreen extends StatefulWidget { + const MainScreen({super.key}); + + @override + _MainScreenState createState() => _MainScreenState(); +} + +class _MainScreenState extends State + with SingleTickerProviderStateMixin { + late final AppLifecycleListener _lifeCycleListener; + final List _screens = [ + const HomeScreen(), + const SearchScreen(), + const LibraryScreen() + ]; + Future? _initialization; + int _selected = 0; + StreamSubscription? _urlLinkStream; + int _keyPressed = 0; + + @override + void initState() { + _lifeCycleListener = + AppLifecycleListener(onStateChange: _onLifeCycleChanged); + _initialization = _init(); + super.initState(); + } + + Future _init() async { + //Set display mode + if ((settings.displayMode ?? -1) >= 0) { + FlutterDisplayMode.supported.then((modes) async { + if (modes.length - 1 >= settings.displayMode!.toInt()) { + FlutterDisplayMode.setPreferredMode( + modes[settings.displayMode!.toInt()]); + } + }); + } + + _preloadFavoriteTracksToCache(); + _initDownloadManager(); + _startStreamingServer(); + await _setupServiceLocator(); + + //Do on BG + GetIt.I().authorizeLastFM(); + + //Start with parameters + _setupDeepLinks(); + _loadPreloadInfo(); + _prepareQuickActions(); + + //Check for updates on background + /* No automatic updates yet + Future.delayed(Duration(seconds: 5), () { + FreezerVersions.checkUpdate(); + }); + */ + + //Restore saved queue + _loadSavedQueue(); + } + + void _preloadFavoriteTracksToCache() async { + try { + cache.libraryTracks = await deezerAPI.getFavoriteTrackIds(); + Logger.root + .info('Cached favorite trackIds: ${cache.libraryTracks?.length}'); + } catch (e, st) { + Logger.root.severe('Error loading favorite trackIds!', e, st); + } + } + + void _initDownloadManager() async { + await downloadManager.init(); + } + + void _startStreamingServer() async { + await DownloadManager.platform + .invokeMethod('startServer', {'arl': settings.arl}); + } + + Future _setupServiceLocator() async { + await setupServiceLocator(); + // Wait for the player to be initialized + await GetIt.I().waitForPlayerInitialization(); + } + + void _prepareQuickActions() { + const QuickActions quickActions = QuickActions(); + quickActions.initialize((type) { + _startPreload(type); + }); + + //Actions + quickActions.setShortcutItems([ + ShortcutItem( + type: 'favorites', + localizedTitle: 'Favorites'.i18n, + icon: 'ic_favorites'), + ShortcutItem(type: 'flow', localizedTitle: 'Flow'.i18n, icon: 'ic_flow'), + ]); + } + + void _startPreload(String type) async { + await deezerAPI.authorize(); + if (type == 'flow') { + await GetIt.I() + .playFromSmartTrackList(SmartTrackList(id: 'flow')); + return; + } + if (type == 'favorites') { + Playlist p = await deezerAPI + .fullPlaylist(deezerAPI.favoritesPlaylistId.toString()); + GetIt.I().playFromPlaylist(p, p.tracks?[0].id ?? ''); + } + } + + void _loadPreloadInfo() async { + String info = + await DownloadManager.platform.invokeMethod('getPreloadInfo') ?? ''; + if (info.isEmpty) return; + _startPreload(info); + } + + Future _loadSavedQueue() async { + GetIt.I().loadQueueFromFile(); + } + + @override + void dispose() { + _urlLinkStream?.cancel(); + _lifeCycleListener.dispose(); + super.dispose(); + } + + void _onLifeCycleChanged(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.detached: + Logger.root.info('App detached.'); + GetIt.I().dispose(); + downloadManager.stop(); + case AppLifecycleState.resumed: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + } + } + + void _setupDeepLinks() async { + AppLinks deepLinks = AppLinks(); + + // Check initial link if app was in cold state (terminated) + final deepLink = await deepLinks.getInitialLinkString(); + if (deepLink != null && deepLink.length > 4) { + Logger.root.info('Opening app from deeplink: $deepLink'); + openScreenByURL(deepLink); + } + + //Listen to URLs when app is in warm state (front or background) + _urlLinkStream = deepLinks.stringLinkStream.listen((deeplink) { + Logger.root.info('Opening deeplink: $deeplink'); + openScreenByURL(deeplink); + }, onError: (e) { + Logger.root.severe('Error handling app link: $e'); + }); + } + + void _handleKey(KeyEvent event, FocusScopeNode navigationBarFocusNode, + FocusNode screenFocusNode) { + FocusNode? primaryFocus = FocusManager.instance.primaryFocus; + + // Movement to navigation bar and back + if (event is KeyDownEvent) { + final logicalKey = event.logicalKey; + final keyCode = logicalKey.keyId; + + if (logicalKey == LogicalKeyboardKey.tvContentsMenu) { + // Menu key on Android TV + focusToNavbar(navigationBarFocusNode); + } else if (keyCode == 0x100070000127) { + // EPG key on Hisense TV (example, you need to find correct LogicalKeyboardKey or define it) + focusToNavbar(navigationBarFocusNode); + } else if (logicalKey == LogicalKeyboardKey.arrowLeft || + logicalKey == LogicalKeyboardKey.arrowRight) { + if ((_keyPressed == LogicalKeyboardKey.arrowLeft.keyId && + logicalKey == LogicalKeyboardKey.arrowRight) || + (_keyPressed == LogicalKeyboardKey.arrowRight.keyId && + logicalKey == LogicalKeyboardKey.arrowLeft)) { + // LEFT + RIGHT + focusToNavbar(navigationBarFocusNode); + } + _keyPressed = logicalKey.keyId; + Future.delayed(const Duration(milliseconds: 100), () { + _keyPressed = 0; + }); + } else if (logicalKey == LogicalKeyboardKey.arrowDown) { + // If it's bottom row, go to navigation bar + var row = primaryFocus?.parent; + if (row != null) { + var column = row.parent; + if (column?.children.last == row) { + focusToNavbar(navigationBarFocusNode); + } + } + } else if (logicalKey == LogicalKeyboardKey.arrowUp) { + if (navigationBarFocusNode.hasFocus) { + screenFocusNode.parent!.parent?.children + .last // children.last is used for handling "playlists" screen in library. Under CustomNavigator 2 screens appears. + .nextFocus(); // nextFocus is used instead of requestFocus because it focuses on last, bottom, non-visible tile of main page + } + } + } + } + + void focusToNavbar(FocusScopeNode navigatorFocusNode) { + navigatorFocusNode.requestFocus(); + navigatorFocusNode.focusInDirection(TraversalDirection + .down); // If player bar is hidden, focus won't be visible, so go down once more + } + + @override + Widget build(BuildContext context) { + FocusScopeNode navigationBarFocusNode = + FocusScopeNode(); // for bottom navigation bar + FocusNode screenFocusNode = FocusNode(); // for CustomNavigator + screenFocusNode.requestFocus(); + + return FutureBuilder( + future: _initialization, + builder: (context, snapshot) { + // Check _initialization status + if (snapshot.connectionState == ConnectionState.done) { + // When _initialization is done, render app + return KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) => + _handleKey(event, navigationBarFocusNode, screenFocusNode), + child: Scaffold( + bottomNavigationBar: FocusScope( + node: navigationBarFocusNode, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const PlayerBar(), + BottomNavigationBar( + backgroundColor: + Theme.of(context).bottomAppBarTheme.color, + currentIndex: _selected, + onTap: (int index) async { + //Pop all routes until home screen + while (customNavigatorKey.currentState!.canPop()) { + await customNavigatorKey.currentState!.maybePop(); + } + + await customNavigatorKey.currentState!.maybePop(); + + setState(() { + _selected = index; + }); + + //Fix statusbar + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + )); + }, + selectedItemColor: Theme.of(context).primaryColor, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: 'Home'.i18n), + BottomNavigationBarItem( + icon: const Icon(Icons.search), + label: 'Search'.i18n, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.library_music), + label: 'Library'.i18n) + ], + ) + ], + )), + body: CustomNavigator( + navigatorKey: customNavigatorKey, + home: Focus( + focusNode: screenFocusNode, + skipTraversal: true, + canRequestFocus: false, + child: _screens[_selected]), + pageRoute: PageRoutes.materialPageRoute), + )); + } else { + // While audio_service is initializing + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + }, + ); + } +} diff --git a/lib/service/audio_service.dart b/lib/service/audio_service.dart new file mode 100644 index 0000000..9801359 --- /dev/null +++ b/lib/service/audio_service.dart @@ -0,0 +1,914 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audio_session/audio_session.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:equalizer_flutter/equalizer_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:refreezer/utils/env.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:scrobblenaut/scrobblenaut.dart'; + +import '../api/cache.dart'; +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../ui/android_auto.dart'; +import '../utils/mediaitem_converter.dart'; + +Future initAudioService() async { + return await AudioService.init( + builder: () => AudioPlayerHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'r.r.refreezer.audio', + androidNotificationChannelName: 'ReFreezer', + androidNotificationOngoing: true, + androidStopForegroundOnPause: true, + androidNotificationClickStartsActivity: true, + androidNotificationChannelDescription: 'ReFreezer', + androidNotificationIcon: 'drawable/ic_logo'), + ); +} + +class AudioPlayerHandler extends BaseAudioHandler + with QueueHandler, SeekHandler { + AudioPlayerHandler() { + _init(); + } + + int? _audioSession; + int? _prevAudioSession; + bool _equalizerOpen = false; + + // for some reason, dart can decide not to respect the 'await' due to weird task sceduling ... + final Completer _playerInitializedCompleter = Completer(); + late AudioPlayer _player; + final _playlist = ConcatenatingAudioSource(children: []); + // Prevent MediaItem change while shuffling or otherwise rearranging the queue by just_audio internals + bool _rearranging = false; + + Scrobblenaut? _scrobblenaut; + bool _scrobblenautReady = false; + // Last logged track id + String? _loggedTrackId; + + //Visualizer + final StreamController _visualizerController = StreamController.broadcast(); + Stream get visualizerStream => _visualizerController.stream; + late StreamSubscription? _visualizerSubscription; + + QueueSource? queueSource; + StreamSubscription? _queueStateSub; + StreamSubscription? _mediaItemSub; + final BehaviorSubject _queueStateSubject = + BehaviorSubject(); + Stream get queueStateStream => _queueStateSubject.stream; + QueueState get queueState => _queueStateSubject.value; + int currentIndex = 0; + + Future _init() async { + await _startSession(); + _playerInitializedCompleter.complete(); + + // Broadcast the current queue when just_audio sequence changes. + // Only emit value when MediaItem list contents is different from previous queue + _player.sequenceStateStream + .map((state) { + try { + return state?.effectiveSequence + .map((source) => source.tag as MediaItem) + .toList(); + } catch (e) { + if (e is RangeError) { + // This is caused by just_audio not updating the currentIndex first in the _broadcastSequence method. + // Because in shufflemode it's out of range after removing items from the playlist. + // Might be fixed in future + Logger.root.severe( + 'RangeError occurred while accessing effectiveSequence: $e'); + // Return null to indicate that the queue could/should not be broadcasted + return null; + } + rethrow; + } + }) + .whereType>() // Filter out null values (error occured). + .distinct((a, b) => listEquals(a, b)) + .pipe(queue); + + // Update current QueueState + _queueStateSub = Rx.combineLatest3, PlaybackState, + List, QueueState>( + queue, + playbackState, + _player.shuffleIndicesStream.whereType>(), + (queue, playbackState, shuffleIndices) => QueueState( + queue, + playbackState.queueIndex, + playbackState.shuffleMode == AudioServiceShuffleMode.all + ? shuffleIndices + : null, + playbackState.repeatMode, + playbackState.shuffleMode, + ), + ) + .where( + (state) => + state.shuffleIndices == null || + state.queue.length == state.shuffleIndices!.length, + ) + .distinct() + .listen(_queueStateSubject.add); + + // Broadcast media item changes after track or position in queue change, + // only emit value when different from previous item + _mediaItemSub = Rx.combineLatest3, bool, MediaItem?>( + _player.currentIndexStream, queue, _player.shuffleModeEnabledStream, + (index, queue, shuffleModeEnabled) { + // Don't broadcast while shuffling to avoid intermediate MediaItem change + if (_rearranging) return null; + + final queueIndex = _getQueueIndex( + index ?? 0, + shuffleModeEnabled: shuffleModeEnabled, + ); + return (queueIndex < queue.length) ? queue[queueIndex] : null; + }).whereType().distinct().listen((item) { + // Change track + mediaItem.add(item); + + final int queueIndex = queue.value.indexOf(item); + final int queueLength = queue.value.length; + + if (queueLength - queueIndex == 1) { + Logger.root.info('loaded last track of queue, adding more tracks'); + _onQueueEnd(); + } + + //Save queue + _saveQueueToFile(); + //Add to history + _addToHistory(item); + }); + + // Propagate all events from the audio player to AudioService clients. + _player.playbackEventStream + .listen(_broadcastState, onError: _playbackError); + + _player.shuffleModeEnabledStream + .listen((enabled) => _broadcastState(_player.playbackEvent)); + + _player.loopModeStream + .listen((mode) => _broadcastState(_player.playbackEvent)); + + _player.processingStateStream.listen((state) { + if (state == ProcessingState.completed && _player.playing) { + stop(); + _player.seek(Duration.zero, index: 0); + } + }); + + //Audio session + _player.androidAudioSessionIdStream.listen((session) { + if (!settings.enableEqualizer) return; + + //Save + _prevAudioSession = _audioSession; + _audioSession = session; + if (_audioSession == null) return; + + //Open EQ + if (!_equalizerOpen) { + EqualizerFlutter.open(session!); + _equalizerOpen = true; + return; + } + + //Change session id + if (_prevAudioSession != _audioSession) { + if (_prevAudioSession != null) { + EqualizerFlutter.removeAudioSessionId(_prevAudioSession!); + } + EqualizerFlutter.setAudioSessionId(_audioSession!); + } + }); + + // When 75% of item played, save loggedTrackId to cache & log listen (if enabled) + AudioService.position.listen((position) { + if (mediaItem.value == null || !playbackState.value.playing) { + return; + } + + if (position.inSeconds > (mediaItem.value!.duration!.inSeconds * 0.75)) { + if (cache.loggedTrackId == mediaItem.value!.id) return; + cache.loggedTrackId = mediaItem.value!.id; + cache.save(); + + //Log to Deezer + if (settings.logListen) { + deezerAPI.logListen(mediaItem.value!.id); + } + } + }); + } + + @override + Future play() async { + _player.play(); + + //Scrobble to LastFM + MediaItem? newMediaItem = mediaItem.value; + if (newMediaItem != null && newMediaItem.id != _loggedTrackId) { + // Add to history if new track + _addToHistory(newMediaItem); + } + } + + @override + Future playFromMediaId(String mediaId, + [Map? extras]) async { + final index = queue.value.indexWhere((item) => item.id == mediaId); + if (index != -1) { + _player.seek( + Duration.zero, + index: + _player.shuffleModeEnabled ? _player.shuffleIndices![index] : index, + ); + } else { + Logger.root.severe('playFromMediaId: MediaItem not found'); + } + } + + @override + Future pause() async { + _player.pause(); + } + + @override + Future stop() async { + Logger.root.info('stopping player'); + await _player.stop(); + await super.stop(); + Logger.root.info('saving queue'); + _saveQueueToFile(); + } + + @override + Future addQueueItem(MediaItem mediaItem) async { + final res = await _itemToSource(mediaItem); + if (res != null) { + await _playlist.add(res); + } + } + + @override + Future addQueueItems(List mediaItems) async { + await _playlist.addAll(await _itemsToSources(mediaItems)); + } + + @override + Future insertQueueItem(int index, MediaItem mediaItem) async { + //-1 == play next + if (index == -1) index = currentIndex + 1; + final res = await _itemToSource(mediaItem); + if (res != null) { + await _playlist.insert(index, res); + } + } + + @override + Future updateQueue(List newQueue) async { + await _playlist.clear(); + if (newQueue.isNotEmpty) { + await _playlist.addAll(await _itemsToSources(newQueue)); + } else { + if (mediaItem.hasValue) { + mediaItem.add(null); + } + } + } + + @override + Future removeQueueItem(MediaItem mediaItem) async { + final queue = this.queue.value; + final index = queue.indexOf(mediaItem); + + if (_player.shuffleModeEnabled) { + // Get the shuffled index of the media item + final shuffledIndex = _player.shuffleIndices!.indexOf(index); + await _playlist.removeAt(shuffledIndex); + } else { + await _playlist.removeAt(index); + } + } + + @override + Future removeQueueItemAt(int index) async { + await _playlist.removeAt(index); + } + + Future moveQueueItem(int currentIndex, int newIndex) async { + _rearranging = true; + await _playlist.move(currentIndex, newIndex); + _rearranging = false; + playbackState.add(playbackState.value.copyWith()); + } + + @override + Future skipToNext() => _player.seekToNext(); + + @override + Future skipToPrevious() async { + if ((_player.position.inSeconds) <= 5) { + _player.seekToPrevious(); + } else { + _player.seek(Duration.zero); + } + } + + @override + Future skipToQueueItem(int index) async { + if (index < 0 || index >= _playlist.children.length) return; + + _player.seek( + Duration.zero, + index: + _player.shuffleModeEnabled ? _player.shuffleIndices![index] : index, + ); + } + + @override + Future seek(Duration position) => _player.seek(position); + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { + playbackState.add(playbackState.value.copyWith(repeatMode: repeatMode)); + await _player.setLoopMode(LoopMode.values[repeatMode.index]); + } + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { + final enabled = shuffleMode == AudioServiceShuffleMode.all; + _rearranging = enabled; + await _player.setShuffleModeEnabled(enabled); + _rearranging = false; + if (enabled) { + await _player.shuffle(); + } + playbackState.add(playbackState.value.copyWith(shuffleMode: shuffleMode)); + } + + @override + Future onTaskRemoved() async { + dispose(); + } + + @override + Future onNotificationDeleted() async { + dispose(); + } + + @override + Future> getChildren( + String parentMediaId, [ + Map? options, + ]) async { + //Android audio callback + AndroidAuto androidAuto = AndroidAuto(); + return androidAuto.getScreen(parentMediaId); + } + + //---------------------------------------------- + // Start internal methods native to AudioHandler + //---------------------------------------------- + + Future _startSession() async { + Logger.root.info('starting audio service...'); + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + + if (settings.ignoreInterruptions == true) { + _player = AudioPlayer(handleInterruptions: false); + // Handle audio interruptions. (ignore) + session.interruptionEventStream.listen((_) {}); + // Handle unplugged headphones. (ignore) + session.becomingNoisyEventStream.listen((_) {}); + } else { + _player = AudioPlayer(); + } + + _loadEmptyPlaylist() + .then((_) => Logger.root.info('audio player initialized!')); + } + + /// Broadcasts the current state to all clients. + void _broadcastState(PlaybackEvent event) { + final playing = _player.playing; + currentIndex = _getQueueIndex(_player.currentIndex ?? 0, + shuffleModeEnabled: _player.shuffleModeEnabled); + + playbackState.add( + playbackState.value.copyWith( + controls: [ + MediaControl.skipToPrevious, + if (playing) MediaControl.pause else MediaControl.play, + MediaControl.skipToNext, + // Custom Stop + const MediaControl( + androidIcon: 'drawable/ic_action_stop', + label: 'stop', + action: MediaAction.stop), + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward + }, + androidCompactActionIndices: const [0, 1, 2], + processingState: const { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, + playing: playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + queueIndex: currentIndex, + ), + ); + } + + /// Resolve the effective queue index taking into account shuffleMode. + int _getQueueIndex(int currentIndex, {bool shuffleModeEnabled = false}) { + final effectiveIndices = _player.effectiveIndices ?? []; + final shuffleIndicesInv = List.filled(effectiveIndices.length, 0); + for (var i = 0; i < effectiveIndices.length; i++) { + shuffleIndicesInv[effectiveIndices[i]] = i; + } + return (shuffleModeEnabled && (currentIndex < shuffleIndicesInv.length)) + ? shuffleIndicesInv[currentIndex] + : currentIndex; + } + + Future _loadEmptyPlaylist() async { + try { + Logger.root.info('Loading empty playlist...'); + await _player.setAudioSource(_playlist); + } catch (e) { + Logger.root.severe('Error loading empty playlist: $e'); + } + } + + Future> _itemsToSources(List mediaItems) async { + var sources = await Future.wait(mediaItems.map(_itemToSource)); + return sources.whereType().toList(); + } + + Future _itemToSource(MediaItem mi) async { + String? url = await _getTrackUrl(mi); + if (url == null) return null; + if (url.startsWith('http')) { + return ProgressiveAudioSource(Uri.parse(url), tag: mi); + } + return AudioSource.uri(Uri.parse(url), tag: mi); + } + + Future _getTrackUrl(MediaItem mediaItem) async { + //Check if offline + String offlinePath = + p.join((await getExternalStorageDirectory())!.path, 'offline/'); + File f = File(p.join(offlinePath, mediaItem.id)); + if (await f.exists()) { + //return f.path; + //Stream server URL + return 'http://localhost:36958/?id=${mediaItem.id}'; + } + + //Show episode direct link + if (mediaItem.extras?['showUrl'] != null) { + return mediaItem.extras?['showUrl']; + } + + //Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer + //This just returns fake url that contains metadata + int quality = await getStreamQuality(); + + List? streamPlaybackDetails = + jsonDecode(mediaItem.extras?['playbackDetails']); + String streamItemId = mediaItem.id; + + //If Deezer provided a FALLBACK track, use the playbackDetails and id from the fallback track + //for streaming (original stream unavailable) + if (mediaItem.extras?['fallbackId'] != null) { + streamItemId = mediaItem.extras?['fallbackId']; + streamPlaybackDetails = + jsonDecode(mediaItem.extras?['playbackDetailsFallback']); + } + + if ((streamPlaybackDetails ?? []).length < 3) return null; + String url = + 'http://localhost:36958/?q=$quality&id=${mediaItem.id}&streamTrackId=$streamItemId&trackToken=${streamPlaybackDetails?[2]}&mv=${streamPlaybackDetails?[1]}&md5origin=${streamPlaybackDetails?[0]}'; + return url; + } + + /// Get requested stream quality based on connection and settings. + Future getStreamQuality() async { + int quality = settings.getQualityInt(settings.mobileQuality); + List conn = await Connectivity().checkConnectivity(); + if (conn.contains(ConnectivityResult.wifi)) { + quality = settings.getQualityInt(settings.wifiQuality); + } + return quality; + } + + /// Load new queue of MediaItems to just_audio & seek to given index & position + Future _loadQueueAtIndex(List newQueue, int index, + {Duration position = Duration.zero}) async { + //Clear old playlist from just_audio + await _playlist.clear(); + + // Convert new queue to AudioSources playlist & add to just_audio (Concurrent approach) + await _playlist.addAll(await _itemsToSources(newQueue)); + + //Seek to correct position & index + try { + await _player.seek(position, index: index); + } catch (e, st) { + Logger.root.severe('Error loading tracks', e, st); + } + } + + //Replace queue, play specified item index + Future _loadQueueAndPlayAtIndex( + QueueSource newQueueSource, List newQueue, int index) async { + // Pauze platback if playing (Player seems to crash on some devices otherwise) + await pause(); + + queueSource = newQueueSource; + await updateQueue(newQueue); + await setShuffleMode(AudioServiceShuffleMode.none); + await skipToQueueItem(index); + + play(); + } + + /// Attempt to load more tracks when queue ends + Future _onQueueEnd() async { + //Flow + if (queueSource == null) return; + + List tracks = []; + switch (queueSource!.source) { + case 'flow': + tracks = await deezerAPI.flow(); + break; + //SmartRadio/Artist radio + case 'smartradio': + tracks = await deezerAPI.smartRadio(queueSource!.id ?? ''); + break; + //Library shuffle + case 'libraryshuffle': + tracks = await deezerAPI.libraryShuffle(start: queue.value.length); + break; + case 'mix': + tracks = await deezerAPI.playMix(queueSource!.id ?? ''); + break; + case 'playlist': + // Get current position + int pos = queue.value.length; + // Load 25 more tracks from playlist + tracks = + await deezerAPI.playlistTracksPage(queueSource!.id!, pos, nb: 25); + break; + default: + Logger.root.info('Reached end of queue source: ${queueSource!.source}'); + break; + } + + // Deduplicate tracks already in queue with the same id + List queueIds = queue.value.map((mi) => mi.id).toList(); + tracks.removeWhere((track) => queueIds.contains(track.id)); + List extraTracks = + tracks.map((t) => t.toMediaItem()).toList(); + addQueueItems(extraTracks); + } + + void _playbackError(err) { + Logger.root.severe('Playback Error from audioservice: ${err.code}', err); + if (err is PlatformException && + err.code == 'abort' && + err.message == 'Connection aborted') { + return; + } + _onError(err, null); + } + + void _onError(err, stacktrace, {bool stopService = false}) { + Logger.root.severe('Error from audioservice: ${err.code}', err); + if (stopService) stop(); + } + + Future _addToHistory(MediaItem item) async { + if (!_player.playing) return; + + // Scrobble to LastFM + if (_scrobblenautReady && !(_loggedTrackId == item.id)) { + Logger.root.info('scrobbling track ${item.id} to recently LastFM'); + _loggedTrackId = item.id; + await _scrobblenaut?.track.scrobble( + track: item.title, + artist: item.artist ?? '', + album: item.album, + ); + } + + if (cache.history.isNotEmpty && cache.history.last.id == item.id) return; + Logger.root.info('adding track ${item.id} to recently played history'); + cache.history.add(Track.fromMediaItem(item)); + cache.save(); + } + + //Get queue save file path + Future _getQueueFilePath() async { + Directory dir = await getApplicationDocumentsDirectory(); + return p.join(dir.path, 'playback.json'); + } + + //Export queue to JSON + Future _saveQueueToFile() async { + if (_player.currentIndex == 0 && queue.value.isEmpty) return; + + String path = await _getQueueFilePath(); + File f = File(path); + //Create if doesn't exist + if (!await File(path).exists()) { + f = await f.create(); + } + Map data = { + 'index': _player.currentIndex, + 'queue': queue.value + .map>( + (mi) => MediaItemConverter.mediaItemToMap(mi)) + .toList(), + 'position': _player.position.inMilliseconds, + 'queueSource': (queueSource ?? QueueSource()).toJson(), + 'loopMode': LoopMode.values.indexOf(_player.loopMode) + }; + await f.writeAsString(jsonEncode(data)); + } + + //---------------------------------------------------------------------------------------------- + // Start app specific public methods. + // Candidates for refactoring to "customAction"s to be called from the UI (PlayerHelper class)? + //---------------------------------------------------------------------------------------------- + + Future waitForPlayerInitialization() async { + await _playerInitializedCompleter.future; + } + + Future dispose() async { + _queueStateSub?.cancel(); + _mediaItemSub?.cancel(); + await stop(); + await _player.dispose(); + } + + //Restore queue & playback info from path + Future loadQueueFromFile() async { + Logger.root.info('looking for saved queue file...'); + File f = File(await _getQueueFilePath()); + if (await f.exists()) { + Logger.root.info('saved queue file found, loading...'); + Map json = jsonDecode(await f.readAsString()); + List savedQueue = (json['queue'] ?? []) + .map((mi) => (MediaItemConverter.mediaItemFromMap(mi))) + .toList(); + final int lastIndex = json['index'] ?? 0; + final Duration lastPos = Duration(milliseconds: json['position'] ?? 0); + queueSource = QueueSource.fromJson(json['queueSource'] ?? {}); + var repeatType = LoopMode.values[(json['loopMode'] ?? 0)]; + _player.setLoopMode(repeatType); + //Restore queue & Broadcast + await _loadQueueAtIndex(savedQueue, lastIndex, position: lastPos); + Logger.root.info('saved queue loaded from file!'); + } + } + + Future removeSavedQueueFile() async { + String path = await _getQueueFilePath(); + File f = File(path); + if (await f.exists()) { + await f.delete(); + Logger.root.info('saved queue file removed!'); + } + } + + Future authorizeLastFM() async { + if (settings.lastFMPassword == null) return; + String username = settings.lastFMUsername ?? ''; + String password = settings.lastFMPassword ?? ''; + try { + LastFM lastFM = await LastFM.authenticateWithPasswordHash( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: username, + passwordHash: password); + _scrobblenaut = Scrobblenaut(lastFM: lastFM); + _scrobblenautReady = true; + } catch (e) { + Logger.root.severe('Error authorizing LastFM: $e'); + Fluttertoast.showToast(msg: 'Authorization error!'.i18n); + } + } + + Future disableLastFM() async { + _scrobblenaut = null; + _scrobblenautReady = false; + } + + Future toggleShuffle() async { + await setShuffleMode(_player.shuffleModeEnabled + ? AudioServiceShuffleMode.none + : AudioServiceShuffleMode.all); + } + + LoopMode getLoopMode() { + return _player.loopMode; + } + + //Repeat toggle + Future changeRepeat() async { + //Change to next repeat type + switch (_player.loopMode) { + case LoopMode.one: + setRepeatMode(AudioServiceRepeatMode.none); + break; + case LoopMode.all: + setRepeatMode(AudioServiceRepeatMode.one); + break; + default: + setRepeatMode(AudioServiceRepeatMode.all); + break; + } + } + + Future updateQueueQuality() async { + // Update quality by reconverting all items in the queue to new AudioSources + if (_player.playing) { + // Pauze platback if playing (Player seems to crash on some devices otherwise) + await pause(); + await _loadQueueAtIndex(queue.value, queueState.queueIndex ?? 0, + position: _player.position); + await _player.play(); + } else { + await _loadQueueAtIndex(queue.value, queueState.queueIndex ?? 0, + position: _player.position); + } + } + + //Play track from album + Future playFromAlbum(Album album, String trackId) async { + await playFromTrackList(album.tracks ?? [], trackId, + QueueSource(id: album.id, text: album.title, source: 'album')); + } + + //Play mix by track + Future playMix(String trackId, String trackTitle) async { + List tracks = await deezerAPI.playMix(trackId); + playFromTrackList( + tracks, + tracks[0].id ?? '', + QueueSource( + id: trackId, + text: 'Mix based on'.i18n + ' $trackTitle', + source: 'mix')); + } + + //Play from artist top tracks + Future playFromTopTracks( + List tracks, String trackId, Artist artist) async { + await playFromTrackList( + tracks, + trackId, + QueueSource( + id: artist.id, text: 'Top ${artist.name}', source: 'topTracks')); + } + + Future playFromPlaylist(Playlist playlist, String trackId) async { + await playFromTrackList(playlist.tracks ?? [], trackId, + QueueSource(id: playlist.id, text: playlist.title, source: 'playlist')); + } + + //Play episode from show, load whole show as queue + Future playShowEpisode(Show show, List episodes, + {int index = 0}) async { + QueueSource showQueueSource = + QueueSource(id: show.id, text: show.name, source: 'show'); + //Generate media items + List episodeQueue = + episodes.map((e) => e.toMediaItem(show)).toList(); + + //Load and play + await _loadQueueAndPlayAtIndex(showQueueSource, episodeQueue, index); + } + + //Load tracks as queue, play track id, set queue source + Future playFromTrackList( + List tracks, String trackId, QueueSource trackQueueSource) async { + //Generate media items + List trackQueue = + tracks.map((track) => track.toMediaItem()).toList(); + + //Load and play + await _loadQueueAndPlayAtIndex(trackQueueSource, trackQueue, + trackQueue.indexWhere((m) => m.id == trackId)); + } + + //Load smart track list as queue, start from beginning + Future playFromSmartTrackList(SmartTrackList stl) async { + //Load from API if no tracks + if ((stl.tracks?.length ?? 0) == 0) { + if (settings.offlineMode) { + Fluttertoast.showToast( + msg: "Offline mode, can't play flow or smart track lists.".i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + return; + } + + //Flow songs cannot be accessed by smart track list call + if (stl.id == 'flow') { + stl.tracks = await deezerAPI.flow(type: stl.flowType); + } else { + stl = await deezerAPI.smartTrackList(stl.id ?? ''); + } + } + QueueSource queueSource = QueueSource( + id: stl.id, + source: (stl.id == 'flow') ? 'flow' : 'smarttracklist', + text: stl.title ?? + ((stl.id == 'flow') ? 'Flow'.i18n : 'Smart track list'.i18n)); + await playFromTrackList( + stl.tracks ?? [], stl.tracks?[0].id ?? '', queueSource); + } + + //Start visualizer + Future startVisualizer() async { + /* Needs experimental 'visualizer' branch of just_audio + _player.startVisualizer(enableWaveform: false, enableFft: true, captureRate: 15000, captureSize: 128); + _visualizerSubscription = _player.visualizerFftStream.listen((event) { + //Calculate actual values + List out = []; + for (int i = 0; i < event.length / 2; i++) { + int rfk = event[i * 2].toSigned(8); + int ifk = event[i * 2 + 1].toSigned(8); + out.add(log(hypot(rfk, ifk) + 1) / 5.2); + } + //Visualizer data + _visualizerController.add(out); + }); + */ + } + + //Stop visualizer + Future stopVisualizer() async { + if (_visualizerSubscription != null) { + await _visualizerSubscription!.cancel(); + _visualizerSubscription = null; + } + } +} + +class QueueState { + static const QueueState empty = QueueState( + [], 0, [], AudioServiceRepeatMode.none, AudioServiceShuffleMode.none); + + final List queue; + final int? queueIndex; + final List? shuffleIndices; + final AudioServiceRepeatMode repeatMode; + final AudioServiceShuffleMode shuffleMode; + + const QueueState(this.queue, this.queueIndex, this.shuffleIndices, + this.repeatMode, this.shuffleMode); + + bool get hasPrevious => + repeatMode != AudioServiceRepeatMode.none || (queueIndex ?? 0) > 0; + bool get hasNext => + repeatMode != AudioServiceRepeatMode.none || + (queueIndex ?? 0) + 1 < queue.length; + + List get indices => + shuffleIndices ?? List.generate(queue.length, (i) => i); +} diff --git a/lib/service/service_locator.dart b/lib/service/service_locator.dart new file mode 100644 index 0000000..686e4d6 --- /dev/null +++ b/lib/service/service_locator.dart @@ -0,0 +1,12 @@ +import 'package:get_it/get_it.dart'; + +import 'audio_service.dart'; + +GetIt getIt = GetIt.instance; + +Future setupServiceLocator() async { + // services + if (!GetIt.I.isRegistered()) { + getIt.registerSingleton(await initAudioService()); + } +} diff --git a/lib/settings.dart b/lib/settings.dart new file mode 100644 index 0000000..fb9f1a3 --- /dev/null +++ b/lib/settings.dart @@ -0,0 +1,557 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:external_path/external_path.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'api/download.dart'; +import 'main.dart'; +import 'service/audio_service.dart'; +import 'ui/cached_image.dart'; + +part 'settings.g.dart'; + +late Settings settings; + +@JsonSerializable() +class Settings { + //Language + @JsonKey(defaultValue: null) + String? language; + + //Main + @JsonKey(defaultValue: false) + late bool ignoreInterruptions; + @JsonKey(defaultValue: false) + late bool enableEqualizer; + + //Account + String? arl; + @JsonKey(includeFromJson: false) + @JsonKey(includeToJson: false) + bool offlineMode = false; + + //Quality + @JsonKey(defaultValue: AudioQuality.MP3_320) + late AudioQuality wifiQuality; + @JsonKey(defaultValue: AudioQuality.MP3_128) + late AudioQuality mobileQuality; + @JsonKey(defaultValue: AudioQuality.FLAC) + late AudioQuality offlineQuality; + @JsonKey(defaultValue: AudioQuality.FLAC) + late AudioQuality downloadQuality; + + //Download options + String? downloadPath; + + @JsonKey(defaultValue: '%artist% - %title%') + late String downloadFilename; + @JsonKey(defaultValue: true) + late bool albumFolder; + @JsonKey(defaultValue: true) + late bool artistFolder; + @JsonKey(defaultValue: false) + late bool albumDiscFolder; + @JsonKey(defaultValue: false) + late bool overwriteDownload; + @JsonKey(defaultValue: 2) + late int downloadThreads; + @JsonKey(defaultValue: false) + late bool playlistFolder; + @JsonKey(defaultValue: true) + late bool downloadLyrics; + @JsonKey(defaultValue: false) + late bool trackCover; + @JsonKey(defaultValue: true) + late bool albumCover; + @JsonKey(defaultValue: false) + late bool nomediaFiles; + @JsonKey(defaultValue: ', ') + late String artistSeparator; + @JsonKey(defaultValue: '%artist% - %title%') + late String singletonFilename; + @JsonKey(defaultValue: 1400) + late int albumArtResolution; + @JsonKey(defaultValue: [ + 'title', + 'album', + 'artist', + 'track', + 'disc', + 'albumArtist', + 'date', + 'label', + 'isrc', + 'upc', + 'trackTotal', + 'bpm', + 'lyrics', + 'genre', + 'contributors', + 'art' + ]) + late List tags; + + //Appearance + @JsonKey(defaultValue: Themes.Dark) + late Themes theme; + @JsonKey(defaultValue: false) + late bool useSystemTheme; + @JsonKey(defaultValue: true) + late bool colorGradientBackground; + @JsonKey(defaultValue: false) + late bool blurPlayerBackground; + @JsonKey(defaultValue: 'Deezer') + late String font; + @JsonKey(defaultValue: false) + late bool lyricsVisualizer; + @JsonKey(defaultValue: null) + int? displayMode; + + //Colors + @JsonKey(toJson: _colorToJson, fromJson: _colorFromJson) + Color primaryColor = Colors.blue; + + static _colorToJson(Color c) => c.value; + static _colorFromJson(int? v) => v == null ? Colors.blue : Color(v); + + @JsonKey(defaultValue: false) + bool useArtColor = false; + StreamSubscription? _useArtColorSub; + + //Deezer + @JsonKey(defaultValue: 'en') + late String deezerLanguage; + @JsonKey(defaultValue: 'US') + late String deezerCountry; + @JsonKey(defaultValue: false) + late bool logListen; + @JsonKey(defaultValue: null) + String? proxyAddress; + + //LastFM + @JsonKey(defaultValue: null) + String? lastFMUsername; + @JsonKey(defaultValue: null) + String? lastFMPassword; + + //Spotify + @JsonKey(defaultValue: null) + String? spotifyClientId; + @JsonKey(defaultValue: null) + String? spotifyClientSecret; + @JsonKey(defaultValue: null) + SpotifyCredentialsSave? spotifyCredentials; + + Settings({this.downloadPath, this.arl}); + + ThemeData get themeData { + //System theme + if (useSystemTheme) { + if (PlatformDispatcher.instance.platformBrightness == Brightness.light) { + return _themeData[Themes.Light]!; + } else { + if (theme == Themes.Light) return _themeData[Themes.Dark]!; + return _themeData[theme]!; + } + } + //Theme + return _themeData[theme] ?? ThemeData(); + } + + //Get all available fonts + List get fonts { + return ['Deezer', ...GoogleFonts.asMap().keys]; + } + + //JSON to forward into download service + Map getServiceSettings() { + return {'json': jsonEncode(toJson())}; + } + + void updateUseArtColor(bool v) { + useArtColor = v; + if (v) { + //On media item change set color + _useArtColorSub = + GetIt.I().mediaItem.listen((event) async { + if (event == null || event.artUri == null) return; + primaryColor = + await imagesDatabase.getPrimaryColor(event.artUri.toString()); + updateTheme(); + }); + } else { + //Cancel stream subscription + _useArtColorSub?.cancel(); + _useArtColorSub = null; + } + } + + SliderThemeData get _sliderTheme => SliderThemeData( + thumbColor: primaryColor, + activeTrackColor: primaryColor, + inactiveTrackColor: primaryColor.withOpacity(0.2)); + + //Load settings/init + Future loadSettings() async { + String path = await getPath(); + File f = File(path); + if (await f.exists()) { + String data = await f.readAsString(); + return Settings.fromJson(jsonDecode(data)); + } + Settings s = Settings.fromJson({}); + //Set default path, because async + s.downloadPath = (await ExternalPath.getExternalStoragePublicDirectory( + ExternalPath.DIRECTORY_MUSIC)); + s.save(); + return s; + } + + Future save() async { + File f = File(await getPath()); + await f.writeAsString(jsonEncode(toJson())); + downloadManager.updateServiceSettings(); + } + + Future updateAudioServiceQuality() async { + await GetIt.I().updateQueueQuality(); + //Send wifi & mobile quality to audio service isolate + //await GetIt.I().customAction( + // 'updateQuality', {'mobileQuality': getQualityInt(mobileQuality), 'wifiQuality': getQualityInt(wifiQuality)}); + } + + //AudioQuality to deezer int + int getQualityInt(AudioQuality q) { + switch (q) { + case AudioQuality.MP3_128: + return 1; + case AudioQuality.MP3_320: + return 3; + case AudioQuality.FLAC: + return 9; + //Deezer default + default: + return 8; + } + } + + //Check if is dark, can't use theme directly, because of system themes, and Theme.of(context).brightness broke + bool get isDark { + if (useSystemTheme) { + if (PlatformDispatcher.instance.platformBrightness == Brightness.light) { + return false; + } + return true; + } + if (theme == Themes.Light) return false; + return true; + } + + static const deezerBg = Color(0xFF1F1A16); + static const deezerBottom = Color(0xFF1b1714); + TextTheme? get textTheme => (font == 'Deezer') + ? null + : GoogleFonts.getTextTheme(font, + isDark ? ThemeData.dark().textTheme : ThemeData.light().textTheme); + String? get _fontFamily => (font == 'Deezer') ? 'MabryPro' : null; + + //Overrides for the non-deprecated buttons to look like the old ones + OutlinedButtonThemeData get outlinedButtonTheme => OutlinedButtonThemeData( + style: ButtonStyle( + foregroundColor: + WidgetStateProperty.all(isDark ? Colors.white : Colors.black), + side: WidgetStateProperty.all(BorderSide(color: Colors.grey.shade800)), + )); + TextButtonThemeData get textButtonTheme => TextButtonThemeData( + style: ButtonStyle( + foregroundColor: + WidgetStateProperty.all(isDark ? Colors.white : Colors.black), + )); + + Map get _themeData => { + Themes.Light: ThemeData( + useMaterial3: false, + brightness: Brightness.light, + textTheme: textTheme, + fontFamily: _fontFamily, + primaryColor: primaryColor, + sliderTheme: _sliderTheme, + outlinedButtonTheme: outlinedButtonTheme, + textButtonTheme: textButtonTheme, + colorScheme: ColorScheme.fromSwatch().copyWith( + secondary: primaryColor, brightness: Brightness.light), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + trackColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + bottomAppBarTheme: + const BottomAppBarTheme(color: Color(0xfff5f5f5))), + Themes.Dark: ThemeData( + useMaterial3: false, + brightness: Brightness.dark, + textTheme: textTheme, + fontFamily: _fontFamily, + primaryColor: primaryColor, + sliderTheme: _sliderTheme, + outlinedButtonTheme: outlinedButtonTheme, + textButtonTheme: textButtonTheme, + colorScheme: ColorScheme.fromSwatch() + .copyWith(secondary: primaryColor, brightness: Brightness.dark), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + trackColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + bottomAppBarTheme: + const BottomAppBarTheme(color: Color(0xff424242))), + Themes.Deezer: ThemeData( + useMaterial3: false, + brightness: Brightness.dark, + textTheme: textTheme, + fontFamily: _fontFamily, + primaryColor: primaryColor, + sliderTheme: _sliderTheme, + scaffoldBackgroundColor: deezerBg, + dialogBackgroundColor: deezerBottom, + bottomSheetTheme: + const BottomSheetThemeData(backgroundColor: deezerBottom), + cardColor: deezerBg, + outlinedButtonTheme: outlinedButtonTheme, + textButtonTheme: textButtonTheme, + colorScheme: ColorScheme.fromSwatch().copyWith( + secondary: primaryColor, + surface: deezerBg, + brightness: Brightness.dark), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + trackColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + bottomAppBarTheme: const BottomAppBarTheme(color: deezerBottom)), + Themes.Black: ThemeData( + useMaterial3: false, + brightness: Brightness.dark, + textTheme: textTheme, + fontFamily: _fontFamily, + primaryColor: primaryColor, + scaffoldBackgroundColor: Colors.black, + dialogBackgroundColor: Colors.black, + sliderTheme: _sliderTheme, + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Colors.black, + ), + outlinedButtonTheme: outlinedButtonTheme, + textButtonTheme: textButtonTheme, + colorScheme: ColorScheme.fromSwatch().copyWith( + secondary: primaryColor, + surface: Colors.black, + brightness: Brightness.dark), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + trackColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return primaryColor; + } + return null; + }), + ), + bottomAppBarTheme: const BottomAppBarTheme(color: Colors.black)) + }; + + Future getPath() async => + p.join((await getApplicationDocumentsDirectory()).path, 'settings.json'); + + //JSON + factory Settings.fromJson(Map json) => + _$SettingsFromJson(json); + Map toJson() => _$SettingsToJson(this); +} + +enum AudioQuality { MP3_128, MP3_320, FLAC, ASK } + +enum Themes { Light, Dark, Deezer, Black } + +@JsonSerializable() +class SpotifyCredentialsSave { + String? accessToken; + String? refreshToken; + List? scopes; + DateTime? expiration; + + SpotifyCredentialsSave( + {this.accessToken, this.refreshToken, this.scopes, this.expiration}); + + //JSON + factory SpotifyCredentialsSave.fromJson(Map json) => + _$SpotifyCredentialsSaveFromJson(json); + Map toJson() => _$SpotifyCredentialsSaveToJson(this); +} diff --git a/lib/translations.i18n.dart b/lib/translations.i18n.dart new file mode 100644 index 0000000..9d0863f --- /dev/null +++ b/lib/translations.i18n.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:i18n_extension/i18n_extension.dart'; + +import '../languages/crowdin.dart'; +import '../languages/en_us.dart'; + +List languages = [ + Language('en', 'US', 'English'), + Language('ar', 'AR', 'Arabic'), + Language('pt', 'BR', 'Brazil'), + Language('it', 'IT', 'Italian'), + Language('de', 'DE', 'German'), + Language('ru', 'RU', 'Russian'), + Language('es', 'ES', 'Spanish'), + Language('hr', 'HR', 'Croatian'), + Language('el', 'GR', 'Greek'), + Language('ko', 'KO', 'Korean'), + Language('fr', 'FR', 'Baguette'), + Language('he', 'IL', 'Hebrew'), + Language('tr', 'TR', 'Turkish'), + Language('ro', 'RO', 'Romanian'), + Language('id', 'ID', 'Indonesian'), + Language('fa', 'IR', 'Persian'), + Language('pl', 'PL', 'Polish'), + Language('uk', 'UA', 'Ukrainian'), + Language('hu', 'HU', 'Hungarian'), + Language('ur', 'PK', 'Urdu'), + Language('hi', 'IN', 'Hindi'), + Language('sk', 'SK', 'Slovak'), + Language('cs', 'CZ', 'Czech'), + Language('vi', 'VI', 'Vietnamese'), + Language('nl', 'NL', 'Dutch'), + Language('sl', 'SL', 'Slovenian'), + Language('zh', 'CN', 'Chinese'), + Language('fil', 'PH', 'Filipino'), + Language('ast', 'ES', 'Asturian'), + Language('bul', 'BG', 'Bulgarian'), + Language('uwu', 'UWU', 'Furry') +]; +List get supportedLocales => languages.map((l) => l.getLocale).toList(); + +extension Localization on String { + static final _t = Translations.byLocale('en_US') + language_en_us + crowdin; + + String get i18n => localize(this, _t); +} + +class Language { + String name; + String locale; + String country; + Language(this.locale, this.country, this.name); + + Locale get getLocale => Locale(locale, country); +} diff --git a/lib/ui/android_auto.dart b/lib/ui/android_auto.dart new file mode 100644 index 0000000..4ebf88f --- /dev/null +++ b/lib/ui/android_auto.dart @@ -0,0 +1,247 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:get_it/get_it.dart'; + +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../service/audio_service.dart'; +import '../translations.i18n.dart'; + +class AndroidAuto { + //Prefix for "playable" MediaItem + static const prefix = '_aa_'; + + //Get media items for parent id + Future> getScreen(String parentId) async { + if (kDebugMode) { + print(parentId); + } + + //Homescreen + if (parentId == 'root') return homeScreen(); + + //Playlists screen + if (parentId == 'playlists') { + //Fetch + List playlists = await deezerAPI.getPlaylists(); + + List out = playlists + .map((p) => MediaItem( + id: '${prefix}playlist${p.id}', + title: p.title ?? '', + album: '', + displayTitle: p.title, + displaySubtitle: p.trackCount.toString() + ' ' + 'Tracks'.i18n, + playable: true, + artUri: Uri.tryParse(p.image?.thumb ?? ''))) + .toList(); + return out; + } + + //Albums screen + if (parentId == 'albums') { + List albums = await deezerAPI.getAlbums(); + + List out = albums + .map((a) => MediaItem( + id: '${prefix}album${a.id}', + title: a.title ?? '', + album: a.title ?? '', + displayTitle: a.title, + displaySubtitle: a.artistString, + playable: true, + artUri: Uri.tryParse(a.art?.thumb ?? ''), + )) + .toList(); + return out; + } + + //Artists screen + if (parentId == 'artists') { + List artists = await deezerAPI.getArtists(); + + List out = artists + .map((a) => MediaItem( + id: 'albums${a.id}', + title: a.name ?? '', + album: '', + displayTitle: a.name, + playable: false, + artUri: Uri.tryParse(a.picture?.thumb ?? ''))) + .toList(); + return out; + } + + //Artist screen (albums, etc) + if (parentId.startsWith('albums')) { + List albums = await deezerAPI.discographyPage(parentId.replaceFirst('albums', '')); + + List out = albums + .map((a) => MediaItem( + id: '${prefix}album${a.id}', + title: a.title ?? '', + album: a.title ?? '', + displayTitle: a.title, + displaySubtitle: a.artistString, + playable: true, + artUri: Uri.tryParse(a.art?.thumb ?? ''))) + .toList(); + return out; + } + + //Homescreen + if (parentId == 'homescreen') { + HomePage hp = await deezerAPI.homePage(); + List out = []; + for (HomePageSection section in hp.sections) { + for (int i = 0; i < (section.items?.length ?? 0); i++) { + //Limit to max 5 items + if (i == 5) break; + + //Check type + var data = section.items![i]?.value; + switch (section.items![i]?.type) { + case HomePageItemType.PLAYLIST: + out.add(MediaItem( + id: '${prefix}playlist${data.id}', + title: data.title, + album: '', + displayTitle: data.title, + playable: true, + artUri: data.image.thumb)); + break; + + case HomePageItemType.ALBUM: + out.add(MediaItem( + id: '${prefix}album${data.id}', + title: data.title, + album: data.title, + displayTitle: data.title, + displaySubtitle: data.artistString, + playable: true, + artUri: data.art.thumb)); + break; + + case HomePageItemType.ARTIST: + out.add(MediaItem( + id: 'albums${data.id}', + title: data.title, + album: '', + displayTitle: data.name, + playable: false, + artUri: data.picture.thumb)); + break; + + case HomePageItemType.SMARTTRACKLIST: + out.add(MediaItem( + id: '${prefix}stl${data.id}', + title: data.title, + album: '', + displayTitle: data.title, + displaySubtitle: data.subtitle, + playable: true, + artUri: data.cover.thumb)); + break; + + default: + break; + } + } + } + + return out; + } + + return []; + } + + //Load virtual mediaItem + Future playItem(String id) async { + if (kDebugMode) { + print(id); + } + + //Play flow + if (id == 'flow' || id == 'stlflow') { + await GetIt.I().playFromSmartTrackList(SmartTrackList(id: 'flow', title: 'Flow'.i18n)); + return; + } + //Play library tracks + if (id == 'tracks') { + //Load tracks + Playlist? favPlaylist; + try { + favPlaylist = await deezerAPI.fullPlaylist(deezerAPI.favoritesPlaylistId ?? ''); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + if ((favPlaylist?.tracks?.length ?? 0) == 0) return; + + await GetIt.I().playFromTrackList(favPlaylist!.tracks!, favPlaylist.tracks![0].id ?? '', + QueueSource(id: 'allTracks', text: 'All offline tracks'.i18n, source: 'offline')); + return; + } + //Play playlists + if (id.startsWith('playlist')) { + Playlist p = await deezerAPI.fullPlaylist(id.replaceFirst('playlist', '')); + await GetIt.I().playFromPlaylist(p, p.tracks?[0].id ?? ''); + return; + } + //Play albums + if (id.startsWith('album')) { + Album a = await deezerAPI.album(id.replaceFirst('album', '')); + await GetIt.I().playFromAlbum(a, a.tracks?[0].id ?? ''); + return; + } + //Play smart track list + if (id.startsWith('stl')) { + SmartTrackList stl = await deezerAPI.smartTrackList(id.replaceFirst('stl', '')); + await GetIt.I().playFromSmartTrackList(stl); + return; + } + } + + //Homescreen items + List homeScreen() { + return [ + MediaItem(id: '${prefix}flow', title: 'Flow'.i18n, album: 'Flow'.i18n, displayTitle: 'Flow'.i18n, playable: true), + MediaItem( + id: 'homescreen', + title: 'Home'.i18n, + album: '', + displayTitle: 'Home'.i18n, + playable: false, + ), + MediaItem( + id: '${prefix}tracks', + title: 'Loved tracks'.i18n, + album: '', + displayTitle: 'Loved tracks'.i18n, + playable: true, + ), + MediaItem( + id: 'playlists', + title: 'Playlists'.i18n, + album: '', + displayTitle: 'Playlists'.i18n, + playable: false, + ), + MediaItem( + id: 'albums', + title: 'Albums'.i18n, + album: '', + displayTitle: 'Albums'.i18n, + playable: false, + ), + MediaItem( + id: 'artists', + title: 'Artists'.i18n, + album: '', + displayTitle: 'Artists'.i18n, + playable: false, + ), + ]; + } +} diff --git a/lib/ui/cached_image.dart b/lib/ui/cached_image.dart new file mode 100644 index 0000000..0fafba5 --- /dev/null +++ b/lib/ui/cached_image.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:photo_view/photo_view.dart'; +import '../translations.i18n.dart'; + +ImagesDatabase imagesDatabase = ImagesDatabase(); + +class ImagesDatabase { + /* + !!! Using the wrappers so i don't have to rewrite most of the code, because of migration to cached network image + */ + + void saveImage(String url) { + CachedNetworkImageProvider(url); + } + + Future getPaletteGenerator(String url) { + return PaletteGenerator.fromImageProvider(CachedNetworkImageProvider(url)); + } + + Future getPrimaryColor(String url) async { + PaletteGenerator paletteGenerator = await getPaletteGenerator(url); + return paletteGenerator.colors.first; + } + + Future isDark(String url) async { + PaletteGenerator paletteGenerator = await getPaletteGenerator(url); + return paletteGenerator.colors.first.computeLuminance() > 0.5 ? false : true; + } +} + +class CachedImage extends StatefulWidget { + final String url; + final double? width; + final double? height; + final bool circular; + final bool fullThumb; + final bool rounded; + + const CachedImage( + {super.key, + required this.url, + this.height, + this.width, + this.circular = false, + this.fullThumb = false, + this.rounded = false}); + + @override + _CachedImageState createState() => _CachedImageState(); +} + +class _CachedImageState extends State { + @override + Widget build(BuildContext context) { + if (widget.rounded) { + return ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: CachedImage( + url: widget.url, + height: widget.height, + width: widget.width, + circular: false, + rounded: false, + fullThumb: widget.fullThumb), + ); + } + + if (widget.circular) { + return ClipOval( + child: CachedImage( + url: widget.url, + height: widget.height, + width: widget.width, + circular: false, + rounded: false, + fullThumb: widget.fullThumb, + )); + } + + if (!widget.url.startsWith('http')) { + return Image.asset( + widget.url, + width: widget.width, + height: widget.height, + ); + } + + return CachedNetworkImage( + imageUrl: widget.url, + width: widget.width, + height: widget.height, + placeholder: (context, url) { + if (widget.fullThumb) { + return Image.asset( + 'assets/cover.jpg', + width: widget.width, + height: widget.height, + ); + } + return Image.asset('assets/cover_thumb.jpg', width: widget.width, height: widget.height); + }, + errorWidget: (context, url, error) => + Image.asset('assets/cover_thumb.jpg', width: widget.width, height: widget.height), + ); + } +} + +class ZoomableImage extends StatefulWidget { + final String url; + final bool rounded; + final double? width; + + const ZoomableImage({super.key, required this.url, this.rounded = false, this.width}); + + @override + _ZoomableImageState createState() => _ZoomableImageState(); +} + +class _ZoomableImageState extends State { + BuildContext? ctx; + PhotoViewController? controller; + bool photoViewOpened = false; + + @override + void initState() { + super.initState(); + controller = PhotoViewController()..outputStateStream.listen(listener); + } + + // Listener of PhotoView scale changes. Used for closing PhotoView by pinch-in + void listener(PhotoViewControllerValue value) { + if (value.scale! < 0.16 && photoViewOpened) { + Navigator.pop(ctx!); + photoViewOpened = false; // to avoid multiple pop() when picture are being scaled out too slowly + } + } + + @override + Widget build(BuildContext context) { + ctx = context; + return TextButton( + child: Semantics( + label: 'Album art'.i18n, + child: CachedImage( + url: widget.url, + rounded: widget.rounded, + width: widget.width, + fullThumb: true, + ), + ), + onPressed: () { + Navigator.of(context).push(PageRouteBuilder( + opaque: false, // transparent background + pageBuilder: (context, a, b) { + photoViewOpened = true; + return PhotoView( + imageProvider: CachedNetworkImageProvider(widget.url), + maxScale: 8.0, + minScale: 0.2, + controller: controller, + backgroundDecoration: const BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0))); + })); + }); + } +} diff --git a/lib/ui/details_screens.dart b/lib/ui/details_screens.dart new file mode 100644 index 0000000..1d8ac93 --- /dev/null +++ b/lib/ui/details_screens.dart @@ -0,0 +1,1288 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'package:draggable_scrollbar/draggable_scrollbar.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttericon/font_awesome5_icons.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:get_it/get_it.dart'; + +import '../api/cache.dart'; +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../api/download.dart'; +import '../service/audio_service.dart'; +import '../translations.i18n.dart'; +import '../ui/elements.dart'; +import '../ui/error.dart'; +import '../ui/search.dart'; +import 'cached_image.dart'; +import 'menu.dart'; +import 'tiles.dart'; + +class AlbumDetails extends StatefulWidget { + final Album album; + const AlbumDetails(this.album, {super.key}); + + @override + _AlbumDetailsState createState() => _AlbumDetailsState(); +} + +class _AlbumDetailsState extends State { + Album album = Album(); + bool _loading = true; + bool _error = false; + + Future _loadAlbum() async { + //Get album from API, if doesn't have tracks + if ((album.tracks ?? []).isEmpty) { + try { + Album a = await deezerAPI.album(album.id ?? ''); + //Preserve library + a.library = album.library; + setState(() => album = a); + } catch (e) { + setState(() => _error = true); + } + } + setState(() => _loading = false); + } + + //Get count of CDs in album + int get cdCount { + int c = 1; + for (Track t in (album.tracks ?? [])) { + if ((t.diskNumber ?? 1) > c) c = t.diskNumber!; + } + return c; + } + + @override + void initState() { + album = widget.album; + _loadAlbum(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _error + ? const ErrorScreen() + : _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + children: [ + //Album art, title, artists + Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 8.0, + ), + ZoomableImage( + url: album.art?.full ?? '', + width: MediaQuery.of(context).size.width / 2, + rounded: true, + ), + Container( + height: 8, + ), + Text( + album.title ?? '', + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + ), + Text( + album.artistString ?? '', + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: TextStyle(fontSize: 16.0, color: Theme.of(context).primaryColor), + ), + Container(height: 4.0), + if (album.releaseDate != null && album.releaseDate!.length >= 4) + Text( + album.releaseDate!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12.0, color: Theme.of(context).disabledColor), + ), + Container( + height: 8.0, + ), + ], + ), + ), + const FreezerDivider(), + //Details + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Icon( + Icons.audiotrack, + size: 32.0, + semanticLabel: 'Tracks'.i18n, + ), + const SizedBox( + width: 8.0, + height: 42.0, + ), //Height to adjust card height + Text( + album.tracks?.length.toString() ?? '0', + style: const TextStyle(fontSize: 16.0), + ) + ], + ), + Row( + children: [ + Icon( + Icons.timelapse, + size: 32.0, + semanticLabel: 'Duration'.i18n, + ), + Container( + width: 8.0, + ), + Text( + album.durationString, + style: const TextStyle(fontSize: 16.0), + ) + ], + ), + Row( + children: [ + Icon(Icons.people, size: 32.0, semanticLabel: 'Fans'.i18n), + Container( + width: 8.0, + ), + Text( + album.fansString ?? '', + style: const TextStyle(fontSize: 16.0), + ) + ], + ), + ], + ), + const FreezerDivider(), + //Options (offline, download...) + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton( + child: Row( + children: [ + Icon((album.library ?? false) ? Icons.favorite : Icons.favorite_border, size: 32), + Container( + width: 4, + ), + Text('Library'.i18n) + ], + ), + onPressed: () async { + //Add to library + if (!(album.library ?? false)) { + await deezerAPI.addFavoriteAlbum(album.id ?? ''); + Fluttertoast.showToast( + msg: 'Added to library'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + setState(() => album.library = true); + return; + } + //Remove + await deezerAPI.removeAlbum(album.id ?? ''); + Fluttertoast.showToast( + msg: 'Album removed from library!'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + setState(() => album.library = false); + }, + ), + MakeAlbumOffline(album: album), + TextButton( + child: Row( + children: [ + const Icon( + Icons.file_download, + size: 32.0, + ), + Container( + width: 4, + ), + Text('Download'.i18n) + ], + ), + onPressed: () async { + if (await downloadManager.addOfflineAlbum(album, private: false) != false) { + MenuSheet().showDownloadStartedToast(); + } + }, + ) + ], + ), + const FreezerDivider(), + ...List.generate(cdCount, (cdi) { + List tracks = []; + if (album.tracks != null) { + tracks.addAll(album.tracks!.where((t) => (t.diskNumber ?? 1) == cdi + 1).toList()); + } + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'Disk'.i18n.toUpperCase() + ' ${cdi + 1}', + style: const TextStyle(fontSize: 12.0, fontWeight: FontWeight.w300), + ), + ), + ...List.generate( + tracks.length, + (i) => TrackTile(tracks[i], onTap: () { + GetIt.I().playFromAlbum(album, tracks[i].id ?? ''); + }, onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(tracks[i], context: context); + })) + ], + ); + }), + ], + )); + } +} + +class MakeAlbumOffline extends StatefulWidget { + final Album? album; + const MakeAlbumOffline({super.key, this.album}); + + @override + _MakeAlbumOfflineState createState() => _MakeAlbumOfflineState(); +} + +class _MakeAlbumOfflineState extends State { + bool _offline = false; + + @override + void initState() { + super.initState(); + downloadManager.checkOffline(album: widget.album).then((v) { + setState(() { + _offline = v; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Switch( + value: _offline, + onChanged: (v) async { + if (v) { + //Add to offline + await deezerAPI.addFavoriteAlbum(widget.album?.id ?? ''); + downloadManager.addOfflineAlbum(widget.album ?? Album(), private: true); + MenuSheet().showDownloadStartedToast(); + setState(() { + _offline = true; + }); + return; + } + downloadManager.removeOfflineAlbum(widget.album?.id ?? ''); + Fluttertoast.showToast( + msg: 'Removed album from offline!'.i18n, gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT); + setState(() { + _offline = false; + }); + }, + ), + Container( + width: 4.0, + ), + Text( + 'Offline'.i18n, + style: const TextStyle(fontSize: 16), + ) + ], + ); + } +} + +class ArtistDetails extends StatefulWidget { + final Artist artist; + const ArtistDetails(this.artist, {super.key}); + + @override + _ArtistDetailsState createState() => _ArtistDetailsState(); +} + +class _ArtistDetailsState extends State { + Artist artist = Artist(); + bool _loading = true; + bool _error = false; + + Future _loadArtist() async { + //Load artist from api if no albums + if (artist.albums.isEmpty) { + try { + Artist a = await deezerAPI.artist(artist.id ?? ''); + setState(() => artist = a); + } catch (e) { + setState(() => _error = true); + } + } + setState(() => _loading = false); + } + + @override + void initState() { + artist = widget.artist; + _loadArtist(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _error + ? const ErrorScreen() + : _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + children: [ + Container(height: 4.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ZoomableImage( + url: artist.picture?.full ?? '', + width: MediaQuery.of(context).size.width / 2 - 8, + rounded: true, + ), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 24, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + artist.name ?? '', + overflow: TextOverflow.ellipsis, + maxLines: 4, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold), + ), + Container( + height: 8.0, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.people, + size: 32.0, + semanticLabel: 'Fans'.i18n, + ), + Container( + width: 8, + ), + Text( + artist.fansString, + style: const TextStyle(fontSize: 16), + ), + ], + ), + Container( + height: 4.0, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.album, + size: 32.0, + semanticLabel: 'Albums'.i18n, + ), + Container( + width: 8.0, + ), + Text( + artist.albumCount.toString(), + style: const TextStyle(fontSize: 16), + ) + ], + ) + ], + ), + ), + ], + ), + Container(height: 4.0), + const FreezerDivider(), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton( + child: Row( + children: [ + const Icon(Icons.favorite, size: 32), + Container( + width: 4, + ), + Text('Library'.i18n) + ], + ), + onPressed: () async { + await deezerAPI.addFavoriteArtist(artist.id ?? ''); + Fluttertoast.showToast( + msg: 'Added to library'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + }, + ), + if ((artist.radio ?? false)) + TextButton( + child: Row( + children: [ + const Icon(Icons.radio, size: 32), + Container( + width: 4, + ), + Text('Radio'.i18n) + ], + ), + onPressed: () async { + List tracks = await deezerAPI.smartRadio(artist.id ?? ''); + if (tracks.isNotEmpty) { + GetIt.I().playFromTrackList( + tracks, + tracks[0].id!, + QueueSource( + id: artist.id, text: 'Radio'.i18n + ' ${artist.name}', source: 'smartradio')); + } + }, + ) + ], + ), + const FreezerDivider(), + Container( + height: 12.0, + ), + //Highlight + if (artist.highlight != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0), + child: Text( + artist.highlight?.title ?? '', + textAlign: TextAlign.left, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20.0), + ), + ), + if ((artist.highlight?.type ?? '') == ArtistHighlightType.ALBUM) + AlbumTile( + artist.highlight?.data, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AlbumDetails(artist.highlight?.data))); + }, + ), + Container(height: 8.0) + ], + ), + //Top tracks + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0), + child: Text( + 'Top Tracks'.i18n, + textAlign: TextAlign.left, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20.0), + ), + ), + Container(height: 4.0), + ...List.generate(5, (i) { + if (artist.topTracks.length <= i) { + return const SizedBox( + height: 0, + width: 0, + ); + } + Track t = artist.topTracks[i]; + return TrackTile( + t, + onTap: () { + GetIt.I().playFromTopTracks(artist.topTracks, t.id!, artist); + }, + onHold: () { + MenuSheet mi = MenuSheet(); + mi.defaultTrackMenu(t, context: context); + }, + ); + }), + ListTile( + title: Text('Show more tracks'.i18n), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => TrackListScreen( + artist.topTracks, + QueueSource( + id: artist.id, text: 'Top'.i18n + '${artist.name}', source: 'topTracks')))); + }), + const FreezerDivider(), + //Albums + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text( + 'Top Albums'.i18n, + textAlign: TextAlign.left, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20.0), + ), + ), + ...List.generate(artist.albums.length > 10 ? 11 : artist.albums.length + 1, (i) { + //Show discography + if (i == 10 || i == artist.albums.length) { + return ListTile( + title: Text('Show all albums'.i18n), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => DiscographyScreen( + artist: artist, + ))); + }); + } + //Top albums + Album a = artist.albums[i]; + return AlbumTile( + a, + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) => AlbumDetails(a))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(a, context: context); + }, + ); + }) + ], + )); + } +} + +class DiscographyScreen extends StatefulWidget { + final Artist artist; + const DiscographyScreen({required this.artist, super.key}); + + @override + _DiscographyScreenState createState() => _DiscographyScreenState(); +} + +class _DiscographyScreenState extends State { + late Artist artist; + bool _loading = false; + bool _error = false; + final List _controllers = [ScrollController(), ScrollController(), ScrollController()]; + + Future _load() async { + if (artist.albums.length >= (artist.albumCount ?? 0) || _loading) return; + setState(() => _loading = true); + + //Fetch data + List data; + try { + data = await deezerAPI.discographyPage(artist.id ?? '', start: artist.albums.length); + } catch (e) { + setState(() { + _error = true; + _loading = false; + }); + return; + } + + //Save + setState(() { + artist.albums.addAll(data); + _loading = false; + }); + } + + //Get album tile + Widget _tile(Album a) => AlbumTile( + a, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => AlbumDetails(a))), + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(a, context: context); + }, + ); + + Widget get _loadingWidget { + if (_loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ); + } + //Error + if (_error) return const ErrorScreen(); + //Success + return const SizedBox( + width: 0, + height: 0, + ); + } + + @override + void initState() { + artist = widget.artist; + + //Lazy loading scroll + for (var c in _controllers) { + c.addListener(() { + double off = c.position.maxScrollExtent * 0.85; + if (c.position.pixels > off) _load(); + }); + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 3, + child: Builder(builder: (BuildContext context) { + final TabController tabController = DefaultTabController.of(context); + tabController.addListener(() { + if (!tabController.indexIsChanging) { + //Load data if empty tabs + int nSingles = artist.albums.where((a) => a.type == AlbumType.SINGLE).length; + int nFeatures = artist.albums.where((a) => a.type == AlbumType.FEATURED).length; + if ((nSingles == 0 || nFeatures == 0) && !_loading) _load(); + } + }); + + return Scaffold( + appBar: FreezerAppBar( + 'Discography'.i18n, + bottom: TabBar( + tabs: [ + Tab( + icon: Icon( + Icons.album, + semanticLabel: 'Albums'.i18n, + )), + Tab(icon: Icon(Icons.audiotrack, semanticLabel: 'Singles'.i18n)), + Tab( + icon: Icon( + Icons.recent_actors, + semanticLabel: 'Featured'.i18n, + )) + ], + ), + height: 100.0, + ), + body: TabBarView( + children: [ + //Albums + ListView.builder( + controller: _controllers[0], + itemCount: artist.albums.length + 1, + itemBuilder: (context, i) { + if (i == artist.albums.length) return _loadingWidget; + if (artist.albums[i].type == AlbumType.ALBUM) { + return _tile(artist.albums[i]); + } + return const SizedBox( + width: 0, + height: 0, + ); + }, + ), + //Singles + ListView.builder( + controller: _controllers[1], + itemCount: artist.albums.length + 1, + itemBuilder: (context, i) { + if (i == artist.albums.length) return _loadingWidget; + if (artist.albums[i].type == AlbumType.SINGLE) { + return _tile(artist.albums[i]); + } + return const SizedBox( + width: 0, + height: 0, + ); + }, + ), + //Featured + ListView.builder( + controller: _controllers[2], + itemCount: artist.albums.length + 1, + itemBuilder: (context, i) { + if (i == artist.albums.length) return _loadingWidget; + if (artist.albums[i].type == AlbumType.FEATURED) { + return _tile(artist.albums[i]); + } + return const SizedBox( + width: 0, + height: 0, + ); + }, + ), + ], + ), + ); + })); + } +} + +class PlaylistDetails extends StatefulWidget { + final Playlist playlist; + const PlaylistDetails(this.playlist, {super.key}); + + @override + _PlaylistDetailsState createState() => _PlaylistDetailsState(); +} + +class _PlaylistDetailsState extends State { + late Playlist playlist; + bool _loading = false; + bool _error = false; + late Sorting _sort; + final ScrollController _scrollController = ScrollController(); + + //Get sorted playlist + List get sorted { + List tracks = List.from(playlist.tracks ?? []); + switch (_sort.type) { + case SortType.ALPHABETIC: + tracks.sort((a, b) => a.title!.compareTo(b.title!)); + break; + case SortType.ARTIST: + tracks.sort((a, b) => a.artists![0].name!.toLowerCase().compareTo(b.artists![0].name!.toLowerCase())); + break; + case SortType.DATE_ADDED: + tracks.sort((a, b) => (a.addedDate ?? 0) - (b.addedDate ?? 0)); + break; + case SortType.DEFAULT: + default: + break; + } + //Reverse + if (_sort.reverse) return tracks.reversed.toList(); + return tracks; + } + + //Load tracks from api + void _loadTracks() async { + // Got all tracks, return + if (_loading || playlist.tracks!.length >= (playlist.trackCount ?? playlist.tracks!.length)) return; + + setState(() => _loading = true); + int pos = playlist.tracks!.length; + //Get another page of tracks + List tracks; + try { + tracks = await deezerAPI.playlistTracksPage(playlist.id!, pos, nb: 25); + } catch (e) { + if (mounted) { + setState(() { + _error = true; + _loading = false; + }); + } + return; + } + + setState(() { + playlist.tracks!.addAll(tracks); + _loading = false; + }); + } + + //Load cached playlist sorting + void _restoreSort() async { + //Find index + int? index = Sorting.index(SortSourceTypes.PLAYLIST, id: playlist.id); + if (index == null) return; + + //Preload tracks + if (playlist.tracks!.length < (playlist.trackCount ?? 0)) { + playlist = await deezerAPI.fullPlaylist(playlist.id!); + } + setState(() => _sort = cache.sorts[index]); + } + + Future _reverse() async { + setState(() => _sort.reverse = !_sort.reverse); + //Save sorting in cache + int? index = Sorting.index(SortSourceTypes.TRACKS); + if (index != null) { + cache.sorts[index] = _sort; + } else { + cache.sorts.add(_sort); + } + await cache.save(); + + //Preload for sorting + if (playlist.tracks!.length < (playlist.trackCount ?? 0)) { + playlist = await deezerAPI.fullPlaylist(playlist.id!); + } + } + + @override + void initState() { + playlist = widget.playlist; + _sort = Sorting(sourceType: SortSourceTypes.PLAYLIST, id: playlist.id); + //If scrolled past 90% load next tracks + _scrollController.addListener(() { + double off = _scrollController.position.maxScrollExtent * 0.90; + if (_scrollController.position.pixels > off) { + _loadTracks(); + } + }); + // Initial load if no tracks + if (playlist.tracks!.isEmpty) { + //Get correct metadata + setState(() => _loading = true); + deezerAPI.playlist(playlist.id!, nb: 25).then((Playlist p) { + setState(() { + playlist = p; + _loading = false; + }); + //Load tracks + //_load(); + }).catchError((e) { + if (mounted) setState(() => _error = true); + }); + } + + _restoreSort(); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: DraggableScrollbar.rrect( + controller: _scrollController, + backgroundColor: Theme.of(context).primaryColor, + child: ListView( + controller: _scrollController, + children: [ + Container( + height: 4.0, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + CachedImage( + url: playlist.image?.full ?? '', + height: MediaQuery.of(context).size.width / 2 - 8, + rounded: true, + fullThumb: true, + ), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 8, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + playlist.title ?? '', + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 3, + style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + ), + Container(height: 4.0), + Text( + playlist.user?.name ?? '', + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 17.0), + ), + Container(height: 10.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.audiotrack, + size: 32.0, + semanticLabel: 'Tracks'.i18n, + ), + Container( + width: 8.0, + ), + Text( + (playlist.trackCount ?? playlist.tracks!.length).toString(), + style: const TextStyle(fontSize: 16), + ) + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timelapse, + size: 32.0, + semanticLabel: 'Duration'.i18n, + ), + Container( + width: 8.0, + ), + Text( + playlist.durationString, + style: const TextStyle(fontSize: 16), + ) + ], + ), + ], + ), + ) + ], + ), + ), + if (playlist.description != null && playlist.description!.isNotEmpty) const FreezerDivider(), + if (playlist.description != null && playlist.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + playlist.description!, + maxLines: 4, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ), + ), + const FreezerDivider(), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + MakePlaylistOffline(playlist), + if (playlist.user?.name != deezerAPI.userName) + IconButton( + icon: Icon( + (playlist.library ?? false) ? Icons.favorite : Icons.favorite_outline, + size: 32, + semanticLabel: (playlist.library ?? false) ? 'Unlove'.i18n : 'Love'.i18n, + ), + onPressed: () async { + //Add to library + if (!(playlist.library ?? false)) { + await deezerAPI.addPlaylist(playlist.id!); + Fluttertoast.showToast( + msg: 'Added to library'.i18n, toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.BOTTOM); + setState(() => playlist.library = true); + return; + } + //Remove + await deezerAPI.removePlaylist(playlist.id!); + Fluttertoast.showToast( + msg: 'Playlist removed from library!'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + setState(() => playlist.library = false); + }, + ), + IconButton( + icon: Icon( + Icons.file_download, + size: 32.0, + semanticLabel: 'Download'.i18n, + ), + onPressed: () async { + if (await downloadManager.addOfflinePlaylist(playlist, private: false) != false) { + MenuSheet().showDownloadStartedToast(); + } + }, + ), + PopupMenuButton( + color: Theme.of(context).scaffoldBackgroundColor, + onSelected: (SortType s) async { + if (playlist.tracks!.length < (playlist.trackCount ?? 0)) { + //Preload whole playlist + playlist = await deezerAPI.fullPlaylist(playlist.id!); + } + setState(() => _sort.type = s); + + //Save sort type to cache + int? index = Sorting.index(SortSourceTypes.PLAYLIST, id: playlist.id); + if (index == null) { + cache.sorts.add(_sort); + } else { + cache.sorts[index] = _sort; + } + await cache.save(); + }, + itemBuilder: (context) => >[ + PopupMenuItem( + value: SortType.DEFAULT, + child: Text('Default'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ALPHABETIC, + child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ARTIST, + child: Text('Artist'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.DATE_ADDED, + child: Text('Date added'.i18n, style: popupMenuTextStyle()), + ), + ], + child: Icon( + Icons.sort, + size: 32.0, + semanticLabel: 'Sort playlist'.i18n, + ), + ), + IconButton( + icon: Icon( + _sort.reverse ? FontAwesome5.sort_alpha_up : FontAwesome5.sort_alpha_down, + semanticLabel: _sort.reverse ? 'Sort descending'.i18n : 'Sort ascending'.i18n, + ), + onPressed: () => _reverse(), + ), + Container(width: 4.0) + ], + ), + const FreezerDivider(), + ...List.generate(playlist.tracks!.length, (i) { + Track t = sorted[i]; + return TrackTile(t, onTap: () { + Playlist p = Playlist(title: playlist.title, id: playlist.id, tracks: sorted); + GetIt.I().playFromPlaylist(p, t.id!); + }, onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(t, context: context, options: [ + (playlist.user?.id == deezerAPI.userId) + ? m.removeFromPlaylist(t, playlist, context) + : const SizedBox( + width: 0, + height: 0, + ) + ]); + }); + }), + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ), + if (_error) const ErrorScreen() + ], + ), + )); + } +} + +class MakePlaylistOffline extends StatefulWidget { + final Playlist playlist; + const MakePlaylistOffline(this.playlist, {super.key}); + + @override + _MakePlaylistOfflineState createState() => _MakePlaylistOfflineState(); +} + +class _MakePlaylistOfflineState extends State { + late Playlist playlist; + bool _offline = false; + + @override + void initState() { + super.initState(); + downloadManager.checkOffline(playlist: widget.playlist).then((v) { + setState(() { + _offline = v; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Switch( + value: _offline, + onChanged: (v) async { + if (v) { + //Add to offline + if (widget.playlist.user?.id != deezerAPI.userId) { + await deezerAPI.addPlaylist(widget.playlist.id!); + } + downloadManager.addOfflinePlaylist(widget.playlist, private: true); + MenuSheet().showDownloadStartedToast(); + setState(() { + _offline = true; + }); + return; + } + downloadManager.removeOfflinePlaylist(widget.playlist.id!); + Fluttertoast.showToast( + msg: 'Playlist removed from offline!'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + setState(() { + _offline = false; + }); + }, + ), + Container( + width: 4.0, + ), + Text( + 'Offline'.i18n, + style: const TextStyle(fontSize: 16), + ) + ], + ); + } +} + +class ShowScreen extends StatefulWidget { + final Show show; + const ShowScreen(this.show, {super.key}); + + @override + _ShowScreenState createState() => _ShowScreenState(); +} + +class _ShowScreenState extends State { + late Show _show; + bool _loading = true; + bool _error = false; + late List _episodes; + + Future _load() async { + //Fetch + List e; + try { + e = await deezerAPI.allShowEpisodes(_show.id!); + } catch (e) { + setState(() { + _loading = false; + _error = true; + }); + return; + } + setState(() { + _episodes = e; + _loading = false; + }); + } + + @override + void initState() { + _show = widget.show; + _load(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar(_show.name!), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + CachedImage( + url: _show.art?.full ?? '', + rounded: true, + width: MediaQuery.of(context).size.width / 2 - 16, + ), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 16, + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Text(_show.name!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold)), + Container(height: 8.0), + Text( + _show.description ?? '', + maxLines: 6, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ) + ], + ), + ) + ], + ), + ), + Container(height: 4.0), + const FreezerDivider(), + + //Error + if (_error) const ErrorScreen(), + + //Loading + if (_loading) + const Padding( + padding: EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ), + + //Data + if (!_loading && !_error) + ...List.generate(_episodes.length, (i) { + ShowEpisode e = _episodes[i]; + return ShowEpisodeTile( + e, + trailing: IconButton( + icon: Icon( + Icons.more_vert, + semanticLabel: 'Options'.i18n, + ), + onPressed: () { + MenuSheet m = MenuSheet(); + m.defaultShowEpisodeMenu(_show, e, context: context); + }, + ), + onTap: () async { + await GetIt.I().playShowEpisode(_show, _episodes, index: i); + }, + ); + }) + ], + ), + ); + } +} diff --git a/lib/ui/downloads_screen.dart b/lib/ui/downloads_screen.dart new file mode 100644 index 0000000..ff67101 --- /dev/null +++ b/lib/ui/downloads_screen.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../api/download.dart'; +import '../translations.i18n.dart'; +import 'elements.dart'; +import 'cached_image.dart'; + +class DownloadsScreen extends StatefulWidget { + const DownloadsScreen({super.key}); + + @override + _DownloadsScreenState createState() => _DownloadsScreenState(); +} + +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() { + _stateSubscription?.cancel(); + _stateSubscription = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'Downloads'.i18n, + actions: [ + IconButton( + icon: Icon( + Icons.delete_sweep, + semanticLabel: 'Clear all'.i18n, + ), + onPressed: () async { + await downloadManager.removeDownloads(DownloadState.ERROR); + await downloadManager.removeDownloads(DownloadState.DEEZER_ERROR); + await downloadManager.removeDownloads(DownloadState.DONE); + await downloadManager.removeDownloads(DownloadState.NONE); + await _load(); + }, + ), + IconButton( + icon: Icon( + downloadManager.running ? Icons.stop : Icons.play_arrow, + semanticLabel: downloadManager.running ? 'Stop'.i18n : 'Start'.i18n, + ), + onPressed: () { + setState(() { + if (downloadManager.running) { + downloadManager.stop(); + } else { + downloadManager.start(); + } + }); + }, + ) + ], + ), + body: ListView( + children: [ + //Now downloading + Container(height: 2.0), + Column( + children: List.generate( + downloading.length, + (int i) => DownloadTile( + downloading[i], + updateCallback: () => _load(), + ))), + Container(height: 8.0), + + //Queued + if (queued.isNotEmpty) + Text( + 'Queued'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold), + ), + Column( + children: List.generate( + queued.length, + (int i) => DownloadTile( + queued[i], + updateCallback: () => _load(), + ))), + if (queued.isNotEmpty) + ListTile( + title: Text('Clear queue'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + await downloadManager.removeDownloads(DownloadState.NONE); + await _load(); + }, + ), + + //Failed + if (failed.isNotEmpty) + Text( + 'Failed'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold), + ), + Column( + children: List.generate( + failed.length, + (int i) => DownloadTile( + failed[i], + updateCallback: () => _load(), + ))), + //Restart failed + if (failed.isNotEmpty) + ListTile( + title: Text('Restart failed downloads'.i18n), + leading: const Icon(Icons.restore), + onTap: () async { + await downloadManager.retryDownloads(); + await _load(); + }, + ), + if (failed.isNotEmpty) + ListTile( + title: Text('Clear failed'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + await downloadManager.removeDownloads(DownloadState.ERROR); + await downloadManager.removeDownloads(DownloadState.DEEZER_ERROR); + await _load(); + }, + ), + + //Finished + if (finished.isNotEmpty) + Text( + 'Done'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold), + ), + Column( + children: List.generate( + finished.length, + (int i) => DownloadTile( + finished[i], + updateCallback: () => _load(), + ))), + if (finished.isNotEmpty) + ListTile( + title: Text('Clear downloads history'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + await downloadManager.removeDownloads(DownloadState.DONE); + await _load(); + }, + ), + ], + )); + } +} + +class DownloadTile extends StatelessWidget { + final Download download; + final Function updateCallback; + const DownloadTile(this.download, {super.key, required this.updateCallback}); + + String subtitle() { + String out = ''; + + if (download.state != DownloadState.DOWNLOADING && download.state != DownloadState.POST) { + //Download type + if (download.private ?? false) { + out += 'Offline'.i18n; + } else { + out += 'External'.i18n; + } + out += ' | '; + } + + if (download.state == DownloadState.POST) { + return 'Post processing...'.i18n; + } + + //Quality + if (download.quality == 9) out += 'FLAC'; + if (download.quality == 3) out += 'MP3 320kbps'; + if (download.quality == 1) out += 'MP3 128kbps'; + + //Downloading show progress + if (download.state == DownloadState.DOWNLOADING) { + out += ' | ${filesize(download.received, 2)} / ${filesize(download.filesize, 2)}'; + double progress = download.received!.toDouble() / download.filesize!.toDouble(); + out += ' ${(progress * 100.0).toStringAsFixed(2)}%'; + } + + 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: [ + TextButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text('Delete'.i18n), + onPressed: () async { + await downloadManager.removeDownload(download.id!); + updateCallback(); + if (context.mounted) Navigator.of(context).pop(); + }, + ) + ], + ); + }); + } + } + + //Trailing icon with state + Widget trailing() { + switch (download.state) { + case DownloadState.NONE: + return const Icon( + Icons.query_builder, + ); + case DownloadState.DOWNLOADING: + return const Icon(Icons.download_rounded); + case DownloadState.POST: + return const Icon(Icons.miscellaneous_services); + case DownloadState.DONE: + return const Icon( + Icons.done, + color: Colors.green, + ); + case DownloadState.DEEZER_ERROR: + return const Icon(Icons.error, color: Colors.blue); + case DownloadState.ERROR: + return const Icon(Icons.error, color: Colors.red); + default: + return Container(); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Text(download.title!), + leading: CachedImage(url: download.image!), + subtitle: Text(subtitle(), maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: trailing(), + onTap: () => onClick(context), + ), + if (download.state == DownloadState.DOWNLOADING) LinearProgressIndicator(value: download.progress), + if (download.state == DownloadState.POST) const LinearProgressIndicator(), + ], + ); + } +} + +class DownloadLogViewer extends StatefulWidget { + const DownloadLogViewer({super.key}); + + @override + _DownloadLogViewerState createState() => _DownloadLogViewerState(); +} + +class _DownloadLogViewerState extends State { + List data = []; + + //Load log from file + Future _load() async { + String path = p.join((await getExternalStorageDirectory())!.path, 'download.log'); + File file = File(path); + if (await file.exists()) { + String d = await file.readAsString(); + setState(() { + data = d.replaceAll('\r', '').split('\n'); + }); + } + } + + //Get color by log type + Color? color(String line) { + if (line.startsWith('E:')) return Colors.red; + if (line.startsWith('W:')) return Colors.orange[600]; + return null; + } + + @override + void initState() { + _load(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Download Log'.i18n), + body: ListView.builder( + itemCount: data.length, + itemBuilder: (context, i) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + data[i], + style: TextStyle(fontSize: 14.0, color: color(data[i])), + ), + ); + }, + )); + } +} diff --git a/lib/ui/elements.dart b/lib/ui/elements.dart new file mode 100644 index 0000000..03028b0 --- /dev/null +++ b/lib/ui/elements.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../settings.dart'; + +class LeadingIcon extends StatelessWidget { + final IconData icon; + final Color? color; + const LeadingIcon(this.icon, {super.key, this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 42.0, + height: 42.0, + decoration: + BoxDecoration(color: (color ?? Theme.of(context).primaryColor).withOpacity(1.0), shape: BoxShape.circle), + child: Icon( + icon, + color: Colors.white, + ), + ); + } +} + +//Container with set size to match LeadingIcon +class EmptyLeading extends StatelessWidget { + const EmptyLeading({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox(width: 42.0, height: 42.0); + } +} + +class FreezerAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + final List actions; + final Widget? bottom; + //Should be specified if bottom is specified + final double height; + + const FreezerAppBar(this.title, {super.key, this.actions = const [], this.bottom, this.height = 56.0}); + + @override + Size get preferredSize => Size.fromHeight(height); + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData(primaryColor: (Theme.of(context).brightness == Brightness.light) ? Colors.white : Colors.black), + child: AppBar( + systemOverlayStyle: SystemUiOverlayStyle(statusBarBrightness: Theme.of(context).brightness), + elevation: 0.0, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + foregroundColor: (Theme.of(context).brightness == Brightness.light) ? Colors.black : Colors.white, + title: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + actions: actions, + bottom: bottom as PreferredSizeWidget?, + ), + ); + } +} + +class FreezerDivider extends StatelessWidget { + const FreezerDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Divider( + thickness: 1.5, + indent: 16.0, + endIndent: 16.0, + ); + } +} + +TextStyle popupMenuTextStyle() { + return TextStyle(color: settings.isDark ? Colors.white : Colors.black); +} diff --git a/lib/ui/error.dart b/lib/ui/error.dart new file mode 100644 index 0000000..75ed5e6 --- /dev/null +++ b/lib/ui/error.dart @@ -0,0 +1,62 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; +import '../translations.i18n.dart'; + +int counter = 0; + +class ErrorScreen extends StatefulWidget { + final String? message; + const ErrorScreen({this.message, super.key}); + + @override + _ErrorScreenState createState() => _ErrorScreenState(); +} + +class _ErrorScreenState extends State { + bool checkArl = false; + + @override + void initState() { + Connectivity().checkConnectivity().then((connectivity) { + if (connectivity.isNotEmpty && !connectivity.contains(ConnectivityResult.none) && counter > 3) { + setState(() { + checkArl = true; + }); + } + }); + + counter += 1; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error, + color: Colors.red, + size: 64.0, + ), + Container( + height: 4.0, + ), + Text(widget.message ?? 'Please check your connection and try again later...'.i18n), + if (checkArl) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 32.0), + child: Text( + 'Your ARL might be expired, try logging out and logging back in using new ARL or browser.'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12.0, + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/ui/home_screen.dart b/lib/ui/home_screen.dart new file mode 100644 index 0000000..c2b1434 --- /dev/null +++ b/lib/ui/home_screen.dart @@ -0,0 +1,388 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../service/audio_service.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../ui/elements.dart'; +import '../ui/error.dart'; +import '../ui/menu.dart'; +import 'details_screens.dart'; +import 'downloads_screen.dart'; +import 'settings_screen.dart'; +import 'tiles.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const HomeAppBar(), + body: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SafeArea(child: Container()), + const Flexible( + child: HomePageScreen(), + ) + ], + ), + )); + } +} + +class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { + const HomeAppBar({super.key}); + + @override + Size get preferredSize => AppBar().preferredSize; + + @override + Widget build(BuildContext context) { + return FreezerAppBar( + 'Home'.i18n, + actions: [ + IconButton( + icon: Icon( + Icons.file_download, + semanticLabel: 'Download'.i18n, + ), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const DownloadsScreen())); + }, + ), + IconButton( + icon: Icon( + Icons.settings, + semanticLabel: 'Settings'.i18n, + ), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const SettingsScreen())); + }, + ), + ], + ); + } +} + +class FreezerTitle extends StatelessWidget { + const FreezerTitle({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 8), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/icon.png', width: 64, height: 64), + const Text( + 'ReFreezer', + style: TextStyle(fontSize: 56, fontWeight: FontWeight.w900), + ) + ], + ) + ], + ), + ); + } +} + +class HomePageScreen extends StatefulWidget { + final HomePage? homePage; + final DeezerChannel? channel; + const HomePageScreen({this.homePage, this.channel, super.key}); + + @override + _HomePageScreenState createState() => _HomePageScreenState(); +} + +class _HomePageScreenState extends State { + HomePage? _homePage; + bool _cancel = false; + bool _error = false; + + void _loadChannel() async { + HomePage? hp; + //Fetch channel from api + try { + hp = await deezerAPI.getChannel(widget.channel?.target ?? ''); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + if (hp == null) { + //On error + setState(() => _error = true); + return; + } + setState(() => _homePage = hp); + } + + void _loadHomePage() async { + //Load local + try { + HomePage hp = await HomePage().load(); + setState(() => _homePage = hp); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + //On background load from API + try { + if (settings.offlineMode) await deezerAPI.authorize(); + HomePage hp = await deezerAPI.homePage(); + if (_cancel) return; + if (hp.sections.isEmpty) return; + setState(() => _homePage = hp); + //Save to cache + await _homePage?.save(); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + } + + void _load() { + if (widget.channel != null) { + _loadChannel(); + return; + } + if (widget.channel == null && widget.homePage == null) { + _loadHomePage(); + return; + } + if (widget.homePage?.sections == null || + widget.homePage!.sections.isEmpty) { + _loadHomePage(); + return; + } + //Already have data + setState(() => _homePage = widget.homePage); + } + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _cancel = true; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_homePage == null) { + return const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + )); + } + if (_error) return const ErrorScreen(); + return Column( + children: List.generate( + _homePage?.sections.length ?? 0, + (i) { + switch (_homePage!.sections[i].layout) { + case HomePageSectionLayout.ROW: + return HomepageRowSection(_homePage!.sections[i]); + case HomePageSectionLayout.GRID: + return HomePageGridSection(_homePage!.sections[i]); + default: + return HomepageRowSection(_homePage!.sections[i]); + } + }, + )); + } +} + +class HomepageRowSection extends StatelessWidget { + final HomePageSection section; + const HomepageRowSection(this.section, {super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0), + title: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 6.0), + child: Text( + section.title ?? '', + textAlign: TextAlign.left, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w900), + ), + ), + subtitle: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate((section.items?.length ?? 0) + 1, (j) { + //Has more items + if (j == (section.items?.length ?? 0)) { + if (section.hasMore ?? false) { + return TextButton( + child: Text( + 'Show more'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20.0), + ), + onPressed: () => + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Scaffold( + appBar: FreezerAppBar(section.title ?? ''), + body: SingleChildScrollView( + child: HomePageScreen( + channel: + DeezerChannel(target: section.pagePath))), + ), + )), + ); + } + return const SizedBox(height: 0, width: 0); + } + + //Show item + HomePageItem item = section.items![j] ?? HomePageItem(); + return HomePageItemWidget(item); + }), + ), + )); + } +} + +class HomePageGridSection extends StatelessWidget { + final HomePageSection section; + const HomePageGridSection(this.section, {super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0), + title: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 6.0), + child: Text( + section.title ?? '', + textAlign: TextAlign.left, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w900), + ), + ), + subtitle: Wrap( + alignment: WrapAlignment.spaceAround, + children: List.generate(section.items!.length, (i) { + //Item + return HomePageItemWidget(section.items![i] ?? HomePageItem()); + }), + ), + ); + } +} + +class HomePageItemWidget extends StatelessWidget { + final HomePageItem item; + const HomePageItemWidget(this.item, {super.key}); + + @override + Widget build(BuildContext context) { + switch (item.type) { + case HomePageItemType.FLOW: + return FlowTrackListTile( + item.value, + onTap: () { + DeezerFlow deezerFlow = item.value; + GetIt.I().playFromSmartTrackList(SmartTrackList( + id: 'flow', title: deezerFlow.title, flowType: deezerFlow.id)); + }, + ); + case HomePageItemType.SMARTTRACKLIST: + return SmartTrackListTile( + item.value, + onTap: () { + GetIt.I().playFromSmartTrackList(item.value); + }, + ); + case HomePageItemType.ALBUM: + return AlbumCard( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => AlbumDetails(item.value))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(item.value, context: context); + }, + ); + case HomePageItemType.ARTIST: + return ArtistTile( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ArtistDetails(item.value))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultArtistMenu(item.value, context: context); + }, + ); + case HomePageItemType.PLAYLIST: + return PlaylistCardTile( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => PlaylistDetails(item.value))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultPlaylistMenu(item.value, context: context); + }, + ); + case HomePageItemType.CHANNEL: + return ChannelTile( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Scaffold( + appBar: FreezerAppBar(item.value.title.toString()), + body: SingleChildScrollView( + child: HomePageScreen( + channel: item.value, + )), + ))); + }, + ); + case HomePageItemType.SHOW: + return ShowCard( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ShowScreen(item.value))); + }, + ); + default: + return const SizedBox(height: 0, width: 0); + } + } +} diff --git a/lib/ui/importer_screen.dart b/lib/ui/importer_screen.dart new file mode 100644 index 0000000..cb62a70 --- /dev/null +++ b/lib/ui/importer_screen.dart @@ -0,0 +1,668 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:spotify/spotify.dart' as spotify; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../api/importer.dart'; +import '../api/spotify.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../ui/elements.dart'; +import '../ui/menu.dart'; + +class SpotifyImporterV1 extends StatefulWidget { + const SpotifyImporterV1({super.key}); + + @override + _SpotifyImporterV1State createState() => _SpotifyImporterV1State(); +} + +class _SpotifyImporterV1State extends State { + late String _url; + bool _error = false; + bool _loading = false; + SpotifyPlaylist? _data; + + //Load URL + Future _load() async { + setState(() { + _error = false; + _loading = true; + }); + try { + String uri = await SpotifyScrapper.resolveUrl(_url); + + //Error/NonPlaylist + if (uri.split(':')[1] != 'playlist') { + throw Exception(); + } + //Load + SpotifyPlaylist data = await SpotifyScrapper.playlist(uri); + setState(() => _data = data); + return; + } catch (e, st) { + if (kDebugMode) { + print('$e, $st'); + } + setState(() { + _error = true; + _loading = false; + }); + return; + } + } + + //Start importing + Future _start() async { + if (_data != null) { + List? tracks = _data!.toImporter(); + await importer.start(_data!.name!, _data!.description, tracks); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Importer'.i18n), + body: ListView( + children: [ + ListTile( + title: Text('Currently supporting only Spotify, with 100 tracks limit'.i18n), + subtitle: Text('Due to API limitations'.i18n), + leading: const Icon( + Icons.warning, + color: Colors.deepOrangeAccent, + ), + ), + const FreezerDivider(), + Container( + height: 16.0, + ), + Text( + 'Enter your playlist link below'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20.0), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + Expanded( + child: TextField( + onChanged: (String s) => _url = s, + onSubmitted: (String s) { + _url = s; + _load(); + }, + decoration: const InputDecoration(hintText: 'URL'), + ), + ), + IconButton( + icon: Icon( + Icons.search, + semanticLabel: 'Search'.i18n, + ), + onPressed: () => _load(), + ) + ], + ), + ), + Container( + height: 8.0, + ), + + if (_data == null && _loading) + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + if (_error) + ListTile( + title: Text('Error loading URL!'.i18n), + leading: const Icon( + Icons.error, + color: Colors.red, + ), + ), + //Playlist + if (_data != null) ...[ + const FreezerDivider(), + ListTile( + title: Text(_data!.name!), + subtitle: + Text((_data!.description ?? '') == '' ? '${_data!.tracks?.length} tracks' : _data!.description!), + leading: Image.network( + _data!.image ?? 'http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg')), + const ImporterSettings(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), + child: ElevatedButton( + child: Text('Start import'.i18n), + onPressed: () async { + await _start(); + if (context.mounted) { + Navigator.of(context) + .pushReplacement(MaterialPageRoute(builder: (context) => const ImporterStatusScreen())); + } + }, + ), + ), + ] + ], + ), + ); + } +} + +class ImporterSettings extends StatefulWidget { + const ImporterSettings({super.key}); + + @override + _ImporterSettingsState createState() => _ImporterSettingsState(); +} + +class _ImporterSettingsState extends State { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text('Download imported tracks'.i18n), + leading: Switch( + value: importer.download, + onChanged: (v) => setState(() => importer.download = v), + ), + ), + ], + ); + } +} + +class ImporterStatusScreen extends StatefulWidget { + const ImporterStatusScreen({super.key}); + + @override + _ImporterStatusScreenState createState() => _ImporterStatusScreenState(); +} + +class _ImporterStatusScreenState extends State { + bool _done = false; + late StreamSubscription _subscription; + + @override + void initState() { + //If import done mark as not done, to prevent double routing + if (importer.done) { + _done = true; + importer.done = false; + } + + //Update + _subscription = importer.updateStream.listen((event) { + setState(() { + //Unset done so this page doesn't reopen + if (importer.done) { + _done = true; + importer.done = false; + } + }); + }); + + super.initState(); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Importing...'.i18n), + body: ListView( + children: [ + // Spinner + if (!_done) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ), + + // Progress indicator + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.import_export, + size: 24.0, + ), + Container( + width: 4.0, + ), + Text( + '${importer.ok + importer.error}/${importer.tracks.length}', + style: const TextStyle(fontSize: 24.0), + ) + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.done, + size: 24.0, + ), + Container( + width: 4.0, + ), + Text( + '${importer.ok}', + style: const TextStyle(fontSize: 24.0), + ) + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error, + size: 24.0, + ), + Container( + width: 4.0, + ), + Text( + '${importer.error}', + style: const TextStyle(fontSize: 24.0), + ), + ], + ), + + //When Done + if (_done) + TextButton( + child: Text('Playlist menu'.i18n), + onPressed: () { + MenuSheet m = MenuSheet(); + m.defaultPlaylistMenu(importer.playlist!, context: context); + }, + ) + ], + ), + Container(height: 8.0), + const FreezerDivider(), + + //Tracks + ...List.generate(importer.tracks.length, (i) { + ImporterTrack t = importer.tracks[i]; + return ListTile( + leading: t.state.icon, + title: Text(t.title), + subtitle: Text( + t.artists.join(', '), + maxLines: 1, + ), + ); + }) + ], + ), + ); + } +} + +class SpotifyImporterV2 extends StatefulWidget { + const SpotifyImporterV2({super.key}); + + @override + _SpotifyImporterV2State createState() => _SpotifyImporterV2State(); +} + +class _SpotifyImporterV2State extends State { + bool _authorizing = false; + String? _clientId; + String? _clientSecret; + late SpotifyAPIWrapper spotify; + + //Spotify authorization flow + Future _authorize() async { + setState(() => _authorizing = true); + spotify = SpotifyAPIWrapper(); + await spotify.authorize(_clientId ?? '', _clientSecret ?? ''); + //Save credentials + settings.spotifyClientId = _clientId; + settings.spotifyClientSecret = _clientSecret; + await settings.save(); + setState(() => _authorizing = false); + //Redirect + if (mounted) { + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => SpotifyImporterV2Main(spotify))); + } + } + + @override + void initState() { + _clientId = settings.spotifyClientId; + _clientSecret = settings.spotifyClientSecret; + + //Try saved + spotify = SpotifyAPIWrapper(); + spotify.trySaved().then((r) { + if (r) { + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => SpotifyImporterV2Main(spotify))); + } + }); + + super.initState(); + } + + @override + void dispose() { + //Stop server + spotify.cancelAuthorize(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Spotify Importer v2'.i18n), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), + child: Text( + 'This importer requires Spotify Client ID and Client Secret. To obtain them:'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18.0, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + '1. Go to: developer.spotify.com/dashboard and create an app.'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16.0, + ), + )), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), + child: ElevatedButton( + child: Text('Open in Browser'.i18n), + onPressed: () { + launchUrlString('https://developer.spotify.com/dashboard'); + }, + ), + ), + Container(height: 16.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + '2. In the app you just created go to settings, and set the Redirect URL to: '.i18n + + 'http://localhost:42069', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16.0, + ), + )), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), + child: ElevatedButton( + child: Text('Copy the Redirect URL'.i18n), + onPressed: () async { + await Clipboard.setData(const ClipboardData(text: 'http://localhost:42069')); + Fluttertoast.showToast( + msg: 'Copied'.i18n, gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: TextField( + controller: TextEditingController(text: _clientId), + decoration: InputDecoration(labelText: 'Client ID'.i18n), + onChanged: (v) => setState(() => _clientId = v), + ), + ), + Container(width: 16.0), + Flexible( + child: TextField( + controller: TextEditingController(text: _clientSecret), + obscureText: true, + decoration: InputDecoration(labelText: 'Client Secret'.i18n), + onChanged: (v) => setState(() => _clientSecret = v), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), + child: + ElevatedButton(onPressed: (!_authorizing) ? () => _authorize() : null, child: Text('Authorize'.i18n)), + ), + if (_authorizing) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ) + ], + ), + ); + } +} + +class SpotifyImporterV2Main extends StatefulWidget { + final SpotifyAPIWrapper spotify; + const SpotifyImporterV2Main(this.spotify, {super.key}); + + @override + _SpotifyImporterV2MainState createState() => _SpotifyImporterV2MainState(); +} + +class _SpotifyImporterV2MainState extends State { + String? _url; + bool _urlLoading = false; + spotify.Playlist? _urlPlaylist; + bool _playlistsLoading = true; + late List _playlists; + + @override + void initState() { + _loadPlaylists(); + super.initState(); + } + + //Load playlists + Future _loadPlaylists() async { + var pages = widget.spotify.spotify.users.playlists(widget.spotify.me.id ?? ''); + _playlists = List.from(await pages.all()); + setState(() => _playlistsLoading = false); + } + + Future _loadUrl() async { + setState(() => _urlLoading = true); + //Resolve URL + try { + String uri = await SpotifyScrapper.resolveUrl(_url ?? ''); + //Error/NonPlaylist + if (uri.split(':')[1] != 'playlist') { + throw Exception(); + } + //Get playlist + spotify.Playlist playlist = await widget.spotify.spotify.playlists.get(uri.split(':')[2]); + setState(() { + _urlLoading = false; + _urlPlaylist = playlist; + }); + } catch (e) { + Fluttertoast.showToast( + msg: 'Invalid/Unsupported URL'.i18n, gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT); + setState(() => _urlLoading = false); + return; + } + } + + Future _startImport(String title, String description, String id) async { + //Show loading dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => PopScope( + canPop: false, + child: AlertDialog( + title: Text('Please wait...'.i18n), + content: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + )))); + + try { + //Fetch entire playlist + var pages = widget.spotify.spotify.playlists.getTracksByPlaylistId(id); + var all = await pages.all(); + //Map to importer track + List tracks = all + .map((t) => ImporterTrack(t.name!, t.artists!.map((a) => a.name!).toList(), isrc: t.externalIds!.isrc)) + .toList(); + await importer.start(title, description, tracks); + //Route + if (mounted) { + Navigator.of(context).pop(); + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => const ImporterStatusScreen())); + } + } catch (e) { + Fluttertoast.showToast(msg: e.toString(), gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT); + if (mounted) Navigator.of(context).pop(); + return; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Spotify Importer v2'.i18n), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Text('Logged in as: '.i18n + widget.spotify.me.displayName!, + maxLines: 1, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold)), + ), + const FreezerDivider(), + Container(height: 4.0), + Text( + 'Options'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + ), + const ImporterSettings(), + const FreezerDivider(), + Container(height: 4.0), + Text( + 'Import playlists by URL'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + Expanded( + child: TextField( + decoration: InputDecoration(hintText: 'URL'.i18n), + onChanged: (v) => setState(() => _url = v)), + ), + IconButton( + icon: const Icon(Icons.search), + onPressed: () => _loadUrl(), + ) + ], + )), + if (_urlLoading) + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: CircularProgressIndicator(), + ) + ], + ), + if (_urlPlaylist != null) + ListTile( + title: Text(_urlPlaylist!.name!), + subtitle: Text(_urlPlaylist!.description ?? ''), + leading: Image.network(_urlPlaylist!.images?.first.url ?? + 'http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg')), + if (_urlPlaylist != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ElevatedButton( + child: Text('Import'.i18n), + onPressed: () { + _startImport(_urlPlaylist!.name!, _urlPlaylist!.description!, _urlPlaylist!.id!); + })), + + // Playlists + const FreezerDivider(), + Container(height: 4.0), + Text('Playlists'.i18n, + textAlign: TextAlign.center, style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold)), + Container(height: 4.0), + if (_playlistsLoading) + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: CircularProgressIndicator(), + ) + ], + ), + if (!_playlistsLoading) + ...List.generate(_playlists.length, (i) { + spotify.PlaylistSimple p = _playlists[i]; + String imageUrl = 'http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg'; + if (p.images?.isNotEmpty ?? false) { + imageUrl = p.images!.first.url ?? imageUrl; + } + return ListTile( + title: Text(p.name!, maxLines: 1), + subtitle: Text(p.owner!.displayName!, maxLines: 1), + leading: Image.network(imageUrl), + onTap: () { + _startImport(p.name!, '', p.id!); + }, + ); + }) + ], + )); + } +} diff --git a/lib/ui/library.dart b/lib/ui/library.dart new file mode 100644 index 0000000..4d5b7e1 --- /dev/null +++ b/lib/ui/library.dart @@ -0,0 +1,1334 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:draggable_scrollbar/draggable_scrollbar.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttericon/font_awesome5_icons.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +import '../api/cache.dart'; +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../api/download.dart'; +import '../api/importer.dart'; +import '../service/audio_service.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../ui/details_screens.dart'; +import '../ui/downloads_screen.dart'; +import '../ui/elements.dart'; +import '../ui/error.dart'; +import '../ui/importer_screen.dart'; +import '../ui/tiles.dart'; +import 'menu.dart'; +import 'settings_screen.dart'; + +class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget { + const LibraryAppBar({super.key}); + + @override + Size get preferredSize => AppBar().preferredSize; + + @override + Widget build(BuildContext context) { + return FreezerAppBar( + 'Library'.i18n, + actions: [ + IconButton( + icon: Icon( + Icons.file_download, + semanticLabel: 'Download'.i18n, + ), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const DownloadsScreen())); + }, + ), + IconButton( + icon: Icon( + Icons.settings, + semanticLabel: 'Settings'.i18n, + ), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const SettingsScreen())); + }, + ), + ], + ); + } +} + +class LibraryScreen extends StatelessWidget { + const LibraryScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const LibraryAppBar(), + body: ListView( + children: [ + Container( + height: 4.0, + ), + if (!downloadManager.running && downloadManager.queueSize > 0) + ListTile( + title: Text('Downloads'.i18n), + leading: + const LeadingIcon(Icons.file_download, color: Colors.grey), + subtitle: Text( + 'Downloading is currently stopped, click here to resume.' + .i18n), + onTap: () { + downloadManager.start(); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const DownloadsScreen())); + }, + ), + ListTile( + title: Text('Shuffle'.i18n), + leading: const LeadingIcon(Icons.shuffle, color: Color(0xffeca704)), + onTap: () async { + List tracks = await deezerAPI.libraryShuffle(); + GetIt.I().playFromTrackList( + tracks, + tracks[0].id!, + QueueSource( + id: 'libraryshuffle', + source: 'libraryshuffle', + text: 'Library shuffle'.i18n)); + }, + ), + const FreezerDivider(), + ListTile( + title: Text('Tracks'.i18n), + leading: + const LeadingIcon(Icons.audiotrack, color: Color(0xffbe3266)), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const LibraryTracks())); + }, + ), + ListTile( + title: Text('Albums'.i18n), + leading: const LeadingIcon(Icons.album, color: Color(0xff4b2e7e)), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const LibraryAlbums())); + }, + ), + ListTile( + title: Text('Artists'.i18n), + leading: const LeadingIcon(Icons.recent_actors, + color: Color(0xff384697)), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const LibraryArtists())); + }, + ), + ListTile( + title: Text('Playlists'.i18n), + leading: const LeadingIcon(Icons.playlist_play, + color: Color(0xff0880b5)), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const LibraryPlaylists())); + }, + ), + const FreezerDivider(), + ListTile( + title: Text('History'.i18n), + leading: const LeadingIcon(Icons.history, color: Color(0xff009a85)), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const HistoryScreen())); + }, + ), + const FreezerDivider(), + ListTile( + title: Text('Import'.i18n), + leading: const LeadingIcon(Icons.import_export, + color: Color(0xff2ba766)), + subtitle: Text('Import playlists from Spotify'.i18n), + onTap: () { + //Show progress + if (importer.done || importer.busy) { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const ImporterStatusScreen())); + return; + } + + //Pick importer dialog + showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text('Importer'.i18n), + children: [ + ListTile( + leading: const Icon(FontAwesome5.spotify), + title: Text('Spotify v1'.i18n), + subtitle: Text( + 'Import Spotify playlists up to 100 tracks without any login.' + .i18n), + enabled: + false, // Spotify reworked embedded playlist. Source format is changed and data no longer contains ISRC. + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => + const SpotifyImporterV1())); + }, + ), + ListTile( + leading: const Icon(FontAwesome5.spotify), + title: Text('Spotify v2'.i18n), + subtitle: Text( + 'Import any Spotify playlist, import from own Spotify library. Requires free account.' + .i18n), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => + const SpotifyImporterV2())); + }, + ) + ], + )); + }, + ), + ExpansionTile( + title: Text('Statistics'.i18n), + leading: const LeadingIcon(Icons.insert_chart, color: Colors.grey), + children: [ + FutureBuilder( + future: downloadManager.getStats(), + builder: (context, snapshot) { + if (snapshot.hasError) return const ErrorScreen(); + if (!snapshot.hasData) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ); + } + List data = snapshot.data!; + return Column( + children: [ + ListTile( + title: Text('Offline tracks'.i18n), + leading: const Icon(Icons.audiotrack), + trailing: Text(data[0]), + ), + ListTile( + title: Text('Offline albums'.i18n), + leading: const Icon(Icons.album), + trailing: Text(data[1]), + ), + ListTile( + title: Text('Offline playlists'.i18n), + leading: const Icon(Icons.playlist_add), + trailing: Text(data[2]), + ), + ListTile( + title: Text('Offline size'.i18n), + leading: const Icon(Icons.sd_card), + trailing: Text(data[3]), + ), + ListTile( + title: Text('Free space'.i18n), + leading: const Icon(Icons.disc_full), + trailing: Text(data[4]), + ), + ], + ); + }, + ) + ], + ) + ], + ), + ); + } +} + +class LibraryTracks extends StatefulWidget { + const LibraryTracks({super.key}); + + @override + _LibraryTracksState createState() => _LibraryTracksState(); +} + +class _LibraryTracksState extends State { + bool _loading = false; + bool _loadingTracks = false; + final ScrollController _scrollController = ScrollController(); + List tracks = []; + List allTracks = []; + int? trackCount; + Sorting _sort = Sorting(sourceType: SortSourceTypes.TRACKS); + + Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId); + + List get _sorted { + List tcopy = List.from(tracks); + tcopy.sort((a, b) => a.addedDate!.compareTo(b.addedDate!)); + switch (_sort.type) { + case SortType.ALPHABETIC: + tcopy.sort((a, b) => a.title!.compareTo(b.title!)); + break; + case SortType.ARTIST: + tcopy.sort((a, b) => a.artists![0].name! + .toLowerCase() + .compareTo(b.artists![0].name!.toLowerCase())); + break; + case SortType.DEFAULT: + default: + break; + } + //Reverse + if (_sort.reverse) return tcopy.reversed.toList(); + return tcopy; + } + + Future _reverse() async { + if (mounted) setState(() => _sort.reverse = !_sort.reverse); + //Save sorting in cache + int? index = Sorting.index(SortSourceTypes.TRACKS); + if (index != null) { + cache.sorts[index] = _sort; + } else { + cache.sorts.add(_sort); + } + await cache.save(); + + //Preload for sorting + if (tracks.length < (trackCount ?? 0)) _loadFull(); + } + + Future _load() async { + //Already loaded + if (trackCount != null && (tracks.length >= (trackCount ?? 0))) { + //Update favorite tracks cache when fully loaded + if (cache.libraryTracks?.length != trackCount) { + if (mounted) { + setState(() { + cache.libraryTracks = tracks.map((t) => t.id!).toList(); + }); + await cache.save(); + } + } + return; + } + + List connectivity = + await Connectivity().checkConnectivity(); + if (connectivity.isNotEmpty && + !connectivity.contains(ConnectivityResult.none)) { + if (mounted) setState(() => _loading = true); + int pos = tracks.length; + + if (tracks.isEmpty) { + //Load tracks as a playlist + Playlist? favPlaylist; + try { + favPlaylist = + await deezerAPI.playlist(deezerAPI.favoritesPlaylistId ?? ''); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + //Error loading + if (favPlaylist == null) { + if (mounted) setState(() => _loading = false); + return; + } + //Update + if (mounted) { + setState(() { + trackCount = favPlaylist!.trackCount; + if (tracks.isEmpty) tracks = favPlaylist.tracks!; + _makeFavorite(); + _loading = false; + }); + } + return; + } + + //Load another page of tracks from deezer + if (_loadingTracks) return; + _loadingTracks = true; + + List? t; + try { + t = await deezerAPI.playlistTracksPage( + deezerAPI.favoritesPlaylistId ?? '', pos); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + //On error load offline + if (t == null) { + await _loadOffline(); + return; + } + if (mounted) { + setState(() { + tracks.addAll(t!); + _makeFavorite(); + _loading = false; + _loadingTracks = false; + }); + } + } + } + + //Load all tracks + Future _loadFull() async { + if (tracks.isEmpty || tracks.length < (trackCount ?? 0)) { + late Playlist p; + try { + p = await deezerAPI.fullPlaylist(deezerAPI.favoritesPlaylistId ?? ''); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + if (mounted) { + setState(() { + tracks = p.tracks!; + trackCount = p.trackCount; + _sort = _sort; + }); + } + } + } + + Future _loadOffline() async { + Playlist? p = await downloadManager + .getOfflinePlaylist(deezerAPI.favoritesPlaylistId ?? ''); + if (mounted) { + setState(() { + tracks = p?.tracks ?? []; + }); + } + } + + Future _loadAllOffline() async { + List tracks = await downloadManager.allOfflineTracks(); + if (mounted) { + setState(() { + allTracks = tracks; + }); + } + } + + //Update tracks with favorite true + void _makeFavorite() { + for (int i = 0; i < tracks.length; i++) { + tracks[i].favorite = true; + } + } + + @override + void initState() { + _scrollController.addListener(() { + //Load more tracks on scroll + double off = _scrollController.position.maxScrollExtent * 0.90; + if (_scrollController.position.pixels > off) _load(); + }); + + _load(); + //Load all offline tracks + _loadAllOffline(); + + //Load sorting + int? index = Sorting.index(SortSourceTypes.TRACKS); + if (index != null) { + if (mounted) setState(() => _sort = cache.sorts[index]); + } + + if (_sort.type != SortType.DEFAULT || _sort.reverse) _loadFull(); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'Tracks'.i18n, + actions: [ + IconButton( + icon: Icon( + _sort.reverse + ? FontAwesome5.sort_alpha_up + : FontAwesome5.sort_alpha_down, + semanticLabel: _sort.reverse + ? 'Sort descending'.i18n + : 'Sort ascending'.i18n, + ), + onPressed: () async { + await _reverse(); + }), + PopupMenuButton( + color: Theme.of(context).scaffoldBackgroundColor, + onSelected: (SortType s) async { + //Preload for sorting + if (tracks.length < (trackCount ?? 0)) await _loadFull(); + + setState(() => _sort.type = s); + //Save sorting in cache + int? index = Sorting.index(SortSourceTypes.TRACKS); + if (index != null) { + cache.sorts[index] = _sort; + } else { + cache.sorts.add(_sort); + } + await cache.save(); + }, + itemBuilder: (context) => >[ + PopupMenuItem( + value: SortType.DEFAULT, + child: Text('Default'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ALPHABETIC, + child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ARTIST, + child: Text('Artist'.i18n, style: popupMenuTextStyle()), + ), + ], + child: Icon( + Icons.sort, + size: 32.0, + semanticLabel: 'Sort'.i18n, + ), + ), + Container(width: 8.0), + ], + ), + body: DraggableScrollbar.rrect( + controller: _scrollController, + backgroundColor: Theme.of(context).primaryColor, + child: ListView( + controller: _scrollController, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + MakePlaylistOffline(_playlist), + TextButton( + child: Row( + children: [ + const Icon( + Icons.file_download, + size: 32.0, + ), + Container( + width: 4, + ), + Text('Download'.i18n) + ], + ), + onPressed: () async { + if (await downloadManager.addOfflinePlaylist(_playlist, + private: false) != + false) { + MenuSheet().showDownloadStartedToast(); + } + }, + ) + ], + ), + const FreezerDivider(), + //Loved tracks + ...List.generate(tracks.length, (i) { + Track t = (tracks.length == (trackCount ?? 0)) + ? _sorted[i] + : tracks[i]; + return TrackTile( + t, + onTap: () { + GetIt.I().playFromTrackList( + (tracks.length == (trackCount ?? 0)) + ? _sorted + : tracks, + t.id!, + QueueSource( + id: deezerAPI.favoritesPlaylistId, + text: 'Favorites'.i18n, + source: 'playlist')); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(t, context: context, onRemove: () { + setState(() { + tracks.removeWhere((track) => t.id == track.id); + }); + }); + }, + ); + }), + if (_loading) + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: CircularProgressIndicator(), + ) + ], + ), + const FreezerDivider(), + Text( + 'All offline tracks'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, fontWeight: FontWeight.bold), + ), + Container( + height: 8, + ), + ...List.generate(allTracks.length, (i) { + Track t = allTracks[i]; + return TrackTile( + t, + onTap: () { + GetIt.I().playFromTrackList( + allTracks, + t.id!, + QueueSource( + id: 'allTracks', + text: 'All offline tracks'.i18n, + source: 'offline')); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(t, context: context); + }, + ); + }) + ], + ))); + } +} + +class LibraryAlbums extends StatefulWidget { + const LibraryAlbums({super.key}); + + @override + _LibraryAlbumsState createState() => _LibraryAlbumsState(); +} + +class _LibraryAlbumsState extends State { + List? _albums; + Sorting _sort = Sorting(sourceType: SortSourceTypes.ALBUMS); + final ScrollController _scrollController = ScrollController(); + + List get _sorted { + List albums = List.from(_albums ?? []); + if (albums.isNotEmpty) { + albums.sort((a, b) => a.favoriteDate!.compareTo(b.favoriteDate!)); + switch (_sort.type) { + case SortType.DEFAULT: + break; + case SortType.ALPHABETIC: + albums.sort((a, b) => + a.title!.toLowerCase().compareTo(b.title!.toLowerCase())); + break; + case SortType.ARTIST: + albums.sort((a, b) => a.artists![0].name! + .toLowerCase() + .compareTo(b.artists![0].name!.toLowerCase())); + break; + case SortType.RELEASE_DATE: + albums.sort((a, b) => DateTime.parse(a.releaseDate!) + .compareTo(DateTime.parse(b.releaseDate!))); + break; + default: + break; + } + } + //Reverse + if (_sort.reverse) return albums.reversed.toList(); + return albums; + } + + Future _load() async { + if (settings.offlineMode) return; + try { + List albums = await deezerAPI.getAlbums(); + if (mounted) setState(() => _albums = albums); + } catch (e) { + Logger.root.severe('Error loading albums: $e', StackTrace); + } + } + + @override + void initState() { + _load(); + //Load sorting + int? index = Sorting.index(SortSourceTypes.ALBUMS); + if (index != null) { + _sort = cache.sorts[index]; + } + + super.initState(); + } + + Future _reverse() async { + setState(() => _sort.reverse = !_sort.reverse); + //Save sorting in cache + int? index = Sorting.index(SortSourceTypes.ALBUMS); + if (index != null) { + cache.sorts[index] = _sort; + } else { + cache.sorts.add(_sort); + } + await cache.save(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'Albums'.i18n, + actions: [ + IconButton( + icon: Icon( + _sort.reverse + ? FontAwesome5.sort_alpha_up + : FontAwesome5.sort_alpha_down, + semanticLabel: _sort.reverse + ? 'Sort descending'.i18n + : 'Sort ascending'.i18n, + ), + onPressed: () => _reverse(), + ), + PopupMenuButton( + color: Theme.of(context).scaffoldBackgroundColor, + child: const Icon(Icons.sort, size: 32.0), + onSelected: (SortType s) async { + setState(() => _sort.type = s); + //Save to cache + int? index = Sorting.index(SortSourceTypes.ALBUMS); + if (index == null) { + cache.sorts.add(_sort); + } else { + cache.sorts[index] = _sort; + } + await cache.save(); + }, + itemBuilder: (context) => >[ + PopupMenuItem( + value: SortType.DEFAULT, + child: Text('Default'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ALPHABETIC, + child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ARTIST, + child: Text('Artist'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.RELEASE_DATE, + child: Text('Release date'.i18n, style: popupMenuTextStyle()), + ), + ], + ), + Container(width: 8.0), + ], + ), + body: DraggableScrollbar.rrect( + controller: _scrollController, + backgroundColor: Theme.of(context).primaryColor, + child: ListView( + controller: _scrollController, + children: [ + Container( + height: 8.0, + ), + if (!settings.offlineMode && _albums == null) + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + if (_albums != null) + ...List.generate(_albums?.length ?? 0, (int i) { + Album a = _sorted[i]; + return AlbumTile( + a, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => AlbumDetails(a))); + }, + onHold: () async { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(a, context: context, onRemove: () { + setState(() => _albums?.remove(a)); + }); + }, + ); + }), + FutureBuilder( + future: downloadManager.getOfflineAlbums(), + builder: (context, snapshot) { + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data!.isEmpty) { + return const SizedBox( + height: 0, + width: 0, + ); + } + + List albums = snapshot.data!; + return Column( + children: [ + const FreezerDivider(), + Text( + 'Offline albums'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 24.0), + ), + ...List.generate(albums.length, (i) { + Album a = albums[i]; + return AlbumTile( + a, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => AlbumDetails(a))); + }, + onHold: () async { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(a, context: context, + onRemove: () { + setState(() { + albums.remove(a); + _albums?.remove(a); + }); + }); + }, + ); + }) + ], + ); + }, + ) + ], + ), + )); + } +} + +class LibraryArtists extends StatefulWidget { + const LibraryArtists({super.key}); + + @override + _LibraryArtistsState createState() => _LibraryArtistsState(); +} + +class _LibraryArtistsState extends State { + List _artists = []; + Sorting _sort = Sorting(sourceType: SortSourceTypes.ARTISTS); + bool _loading = true; + bool _error = false; + final ScrollController _scrollController = ScrollController(); + + List get _sorted { + List artists = List.from(_artists); + if (artists.isNotEmpty) { + artists.sort((a, b) => a.favoriteDate!.compareTo(b.favoriteDate!)); + switch (_sort.type) { + case SortType.DEFAULT: + break; + case SortType.POPULARITY: + artists.sort((a, b) => b.fans! - a.fans!); + break; + case SortType.ALPHABETIC: + artists.sort( + (a, b) => a.name!.toLowerCase().compareTo(b.name!.toLowerCase())); + break; + default: + break; + } + } + //Reverse + if (_sort.reverse) return artists.reversed.toList(); + return artists; + } + + //Load data + Future _load() async { + if (mounted) setState(() => _loading = true); + //Fetch + List? data; + try { + data = await deezerAPI.getArtists(); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + //Update UI + if (mounted) { + setState(() { + if (data != null) { + _artists = data; + } else { + _error = true; + } + _loading = false; + }); + } + } + + Future _reverse() async { + setState(() => _sort.reverse = !_sort.reverse); + //Save sorting in cache + int? index = Sorting.index(SortSourceTypes.ARTISTS); + if (index != null) { + cache.sorts[index] = _sort; + } else { + cache.sorts.add(_sort); + } + await cache.save(); + } + + @override + void initState() { + //Restore sort + int? index = Sorting.index(SortSourceTypes.ARTISTS); + if (index != null) { + _sort = cache.sorts[index]; + } + + _load(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'Artists'.i18n, + actions: [ + IconButton( + icon: Icon( + _sort.reverse + ? FontAwesome5.sort_alpha_up + : FontAwesome5.sort_alpha_down, + semanticLabel: _sort.reverse + ? 'Sort descending'.i18n + : 'Sort ascending'.i18n, + ), + onPressed: () => _reverse(), + ), + PopupMenuButton( + color: Theme.of(context).scaffoldBackgroundColor, + onSelected: (SortType s) async { + setState(() => _sort.type = s); + //Save + int? index = Sorting.index(SortSourceTypes.ARTISTS); + if (index == null) { + cache.sorts.add(_sort); + } else { + cache.sorts[index] = _sort; + } + await cache.save(); + }, + itemBuilder: (context) => >[ + PopupMenuItem( + value: SortType.DEFAULT, + child: Text('Default'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ALPHABETIC, + child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.POPULARITY, + child: Text('Popularity'.i18n, style: popupMenuTextStyle()), + ), + ], + child: const Icon(Icons.sort, size: 32.0), + ), + Container(width: 8.0), + ], + ), + body: DraggableScrollbar.rrect( + controller: _scrollController, + backgroundColor: Theme.of(context).primaryColor, + child: ListView( + controller: _scrollController, + children: [ + if (_loading) + const Padding( + padding: EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ), + if (_error) const Center(child: ErrorScreen()), + if (!_loading && !_error) + ...List.generate(_artists.length, (i) { + Artist a = _sorted[i]; + return ArtistHorizontalTile( + a, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ArtistDetails(a))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultArtistMenu(a, context: context, onRemove: () { + setState(() { + _artists.remove(a); + }); + }); + }, + ); + }), + ], + ), + )); + } +} + +class LibraryPlaylists extends StatefulWidget { + const LibraryPlaylists({super.key}); + + @override + _LibraryPlaylistsState createState() => _LibraryPlaylistsState(); +} + +class _LibraryPlaylistsState extends State { + List? _playlists; + Sorting _sort = Sorting(sourceType: SortSourceTypes.PLAYLISTS); + final ScrollController _scrollController = ScrollController(); + String _filter = ''; + + List get _sorted { + List playlists = List.from(_playlists! + .where((p) => p.title!.toLowerCase().contains(_filter.toLowerCase()))); + switch (_sort.type) { + case SortType.DEFAULT: + break; + case SortType.USER: + playlists.sort((a, b) => (a.user?.name ?? deezerAPI.userName!) + .toLowerCase() + .compareTo((b.user?.name ?? deezerAPI.userName!).toLowerCase())); + break; + case SortType.TRACK_COUNT: + playlists.sort((a, b) => b.trackCount! - a.trackCount!); + break; + case SortType.ALPHABETIC: + playlists.sort( + (a, b) => a.title!.toLowerCase().compareTo(b.title!.toLowerCase())); + break; + default: + break; + } + if (_sort.reverse) return playlists.reversed.toList(); + return playlists; + } + + Future _load() async { + if (!settings.offlineMode) { + try { + List playlists = await deezerAPI.getPlaylists(); + if (mounted) setState(() => _playlists = playlists); + } catch (e) { + Logger.root.severe('Error loading playlists: $e'); + } + } + } + + Future _reverse() async { + setState(() => _sort.reverse = !_sort.reverse); + //Save sorting in cache + int? index = Sorting.index(SortSourceTypes.PLAYLISTS); + if (index != null) { + cache.sorts[index] = _sort; + } else { + cache.sorts.add(_sort); + } + await cache.save(); + } + + @override + void initState() { + //Restore sort + int? index = Sorting.index(SortSourceTypes.PLAYLISTS); + if (index != null) { + _sort = cache.sorts[index]; + } + + _load(); + super.initState(); + } + + Playlist get favoritesPlaylist => Playlist( + id: deezerAPI.favoritesPlaylistId, + title: 'Favorites'.i18n, + user: User(name: deezerAPI.userName), + image: ImageDetails(thumbUrl: 'assets/favorites_thumb.jpg'), + tracks: [], + trackCount: 1, + duration: const Duration(seconds: 0)); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'Playlists'.i18n, + actions: [ + IconButton( + icon: Icon( + _sort.reverse + ? FontAwesome5.sort_alpha_up + : FontAwesome5.sort_alpha_down, + semanticLabel: _sort.reverse + ? 'Sort descending'.i18n + : 'Sort ascending'.i18n, + ), + onPressed: () => _reverse(), + ), + PopupMenuButton( + color: Theme.of(context).scaffoldBackgroundColor, + onSelected: (SortType s) async { + setState(() => _sort.type = s); + //Save to cache + int? index = Sorting.index(SortSourceTypes.PLAYLISTS); + if (index == null) { + cache.sorts.add(_sort); + } else { + cache.sorts[index] = _sort; + } + + await cache.save(); + }, + itemBuilder: (context) => >[ + PopupMenuItem( + value: SortType.DEFAULT, + child: Text('Default'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.USER, + child: Text('User'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.TRACK_COUNT, + child: Text('Track count'.i18n, style: popupMenuTextStyle()), + ), + PopupMenuItem( + value: SortType.ALPHABETIC, + child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), + ), + ], + child: const Icon(Icons.sort, size: 32.0), + ), + Container(width: 8.0), + ], + ), + body: DraggableScrollbar.rrect( + controller: _scrollController, + backgroundColor: Theme.of(context).primaryColor, + child: ListView( + controller: _scrollController, + children: [ + //Search + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + onChanged: (String s) => setState(() => _filter = s), + decoration: InputDecoration( + labelText: 'Search'.i18n, + fillColor: Theme.of(context).bottomAppBarTheme.color, + filled: true, + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey)), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey)), + )), + ), + ListTile( + title: Text('Create new playlist'.i18n), + leading: const LeadingIcon(Icons.playlist_add, + color: Color(0xff009a85)), + onTap: () async { + if (settings.offlineMode) { + Fluttertoast.showToast( + msg: 'Cannot create playlists in offline mode'.i18n, + gravity: ToastGravity.BOTTOM); + return; + } + MenuSheet m = MenuSheet(); + await m.createPlaylist(context); + await _load(); + }, + ), + const FreezerDivider(), + + if (!settings.offlineMode && _playlists == null) + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + ], + ), + + //Favorites playlist + PlaylistTile( + favoritesPlaylist, + onTap: () async { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => + PlaylistDetails(favoritesPlaylist))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + favoritesPlaylist.library = true; + m.defaultPlaylistMenu(favoritesPlaylist, context: context); + }, + ), + + if (_playlists != null) + ...List.generate(_sorted.length, (int i) { + Playlist p = (_sorted)[i]; + return PlaylistTile( + p, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => PlaylistDetails(p))), + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultPlaylistMenu(p, context: context, onRemove: () { + setState(() => _playlists!.remove(p)); + }, onUpdate: () { + _load(); + }); + }, + ); + }), + + FutureBuilder( + future: downloadManager.getOfflinePlaylists(), + builder: (context, snapshot) { + if (snapshot.hasError || !snapshot.hasData) { + return const SizedBox( + height: 0, + width: 0, + ); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const SizedBox( + height: 0, + width: 0, + ); + } + + List playlists = snapshot.data!; + return Column( + children: [ + const FreezerDivider(), + Text( + 'Offline playlists'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24.0, fontWeight: FontWeight.bold), + ), + ...List.generate(playlists.length, (i) { + Playlist p = playlists[i]; + return PlaylistTile( + p, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PlaylistDetails(p))), + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultPlaylistMenu(p, context: context, + onRemove: () { + setState(() { + playlists.remove(p); + _playlists!.remove(p); + }); + }); + }, + ); + }) + ], + ); + }, + ) + ], + ), + )); + } +} + +class HistoryScreen extends StatefulWidget { + const HistoryScreen({super.key}); + + @override + _HistoryScreenState createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + final ScrollController _scrollController = ScrollController(); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'History'.i18n, + actions: [ + IconButton( + icon: Icon( + Icons.delete_sweep, + semanticLabel: 'Clear all'.i18n, + ), + onPressed: () { + setState(() => cache.history = []); + cache.save(); + }, + ) + ], + ), + body: DraggableScrollbar.rrect( + controller: _scrollController, + backgroundColor: Theme.of(context).primaryColor, + child: ListView.builder( + controller: _scrollController, + itemCount: (cache.history).length, + itemBuilder: (BuildContext context, int i) { + Track t = cache.history[cache.history.length - i - 1]; + return TrackTile( + t, + onTap: () { + GetIt.I().playFromTrackList( + cache.history.reversed.toList(), + t.id!, + QueueSource( + id: null, text: 'History'.i18n, source: 'history')); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(t, context: context); + }, + ); + }, + )), + ); + } +} diff --git a/lib/ui/log_screen.dart b/lib/ui/log_screen.dart new file mode 100644 index 0000000..f5df32d --- /dev/null +++ b/lib/ui/log_screen.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../translations.i18n.dart'; +import 'elements.dart'; + +class ApplicationLogViewer extends StatefulWidget { + const ApplicationLogViewer({super.key}); + + @override + _ApplicationLogViewerState createState() => _ApplicationLogViewerState(); +} + +class _ApplicationLogViewerState extends State { + List data = []; + + //Load log from file + Future _load() async { + String path = p.join((await getExternalStorageDirectory())!.path, 'refreezer.log'); + File file = File(path); + if (await file.exists()) { + String d = await file.readAsString(); + setState(() { + data = d.replaceAll('\r', '').split('\n'); + }); + } + } + + //Get color by log type + Color? color(String line) { + if (line.startsWith('[log] SEVERE:')) return Colors.red; + if (line.startsWith('[log] WARNING:')) return Colors.orange[600]; + return null; + } + + @override + void initState() { + _load(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Application Log'.i18n), + body: ListView.builder( + itemCount: data.length, + itemBuilder: (context, i) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + data[i], + style: TextStyle(fontSize: 14.0, color: color(data[i])), + ), + ); + }, + )); + } +} diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart new file mode 100644 index 0000000..bec2491 --- /dev/null +++ b/lib/ui/login_screen.dart @@ -0,0 +1,438 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:logging/logging.dart'; + +import '../api/deezer.dart'; +import '../api/deezer_login.dart'; +import '../api/definitions.dart'; +import '../utils/navigator_keys.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import 'home_screen.dart'; + +class LoginWidget extends StatefulWidget { + final Function? callback; + const LoginWidget({required this.callback, super.key}); + + @override + _LoginWidgetState createState() => _LoginWidgetState(); +} + +class _LoginWidgetState extends State { + String? _arl; + String? _error; + + //Initialize deezer etc + Future _init() async { + deezerAPI.arl = settings.arl; + //await GetIt.I().start(); + + //Pre-cache homepage + if (!await HomePage().exists()) { + await deezerAPI.authorize(); + settings.offlineMode = false; + HomePage hp = await deezerAPI.homePage(); + if (hp.sections.isNotEmpty) await hp.save(); + } + } + + //Call _init() + void _start() async { + if (settings.arl != null) { + _init().then((_) { + if (widget.callback != null) widget.callback!(); + }); + } + } + + //Check if deezer available in current country + void _checkAvailability() async { + bool? available = await DeezerAPI.checkAvailability(); + if (!(available ?? false)) { + showDialog( + context: mainNavigatorKey.currentContext!, + builder: (context) => AlertDialog( + title: Text('Deezer is unavailable'.i18n), + content: Text( + 'Deezer is unavailable in your country, ReFreezer might not work properly. Please use a VPN' + .i18n), + actions: [ + TextButton( + child: Text('Continue'.i18n), + onPressed: () { + if (context.mounted) Navigator.of(context).pop(); + }, + ) + ], + )); + } + } + + /* No idea why this is needed, seems to trigger superfluous _start() execution... + @override + void didUpdateWidget(LoginWidget oldWidget) { + _start(); + super.didUpdateWidget(oldWidget); + }*/ + + @override + void initState() { + _start(); + _checkAvailability(); + super.initState(); + } + + void errorDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Error'.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: [ + TextButton( + child: Text('Dismiss'.i18n), + onPressed: () { + _error = null; + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + } + + void _update() async { + setState(() => {}); + + //Try logging in + try { + deezerAPI.arl = settings.arl; + bool resp = await deezerAPI.rawAuthorize( + onError: (e) => setState(() => _error = e.toString())); + if (resp == false) { + //false, not null + if ((settings.arl ?? '').length != 192) { + _error = '${(_error ?? '')}Invalid ARL length!'; + } + setState(() => settings.arl = null); + errorDialog(); + } + //On error show dialog and reset to null + } catch (e) { + _error = e.toString(); + if (kDebugMode) { + print('Login error: $e'); + } + setState(() => settings.arl = null); + errorDialog(); + } + + await settings.save(); + _start(); + } + + // ARL auth: called on "Save" click, Enter and DPAD_Center press + void goARL(FocusNode? node, TextEditingController controller) { + node?.unfocus(); + controller.clear(); + settings.arl = _arl?.trim(); + Navigator.of(context).pop(); + _update(); + } + + @override + Widget build(BuildContext context) { + //If arl is null, show loading + if (settings.arl != null) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + TextEditingController controller = TextEditingController(); + // For "DPAD center" key handling on remote controls + FocusNode focusNode = FocusNode( + skipTraversal: true, + descendantsAreFocusable: false, + onKeyEvent: (node, event) { + if (event.logicalKey == LogicalKeyboardKey.select) { + goARL(node, controller); + } + return KeyEventResult.handled; + }); + if (settings.arl == null) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ListView( + children: [ + const FreezerTitle(), + Container( + height: 8.0, + ), + Text( + 'Please login using your Deezer account.'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ), + Container( + height: 16.0, + ), + //Email login dialog + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: OutlinedButton( + child: Text( + 'Login using email'.i18n, + ), + onPressed: () { + showDialog( + context: context, + builder: (context) => EmailLogin(_update)); + }, + )), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: OutlinedButton( + child: Text('Login using browser'.i18n), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => LoginBrowser(_update))); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: OutlinedButton( + child: Text('Login using token'.i18n), + onPressed: () { + showDialog( + context: context, + builder: (context) { + Future.delayed( + const Duration(seconds: 1), + () => { + focusNode.requestFocus() + }); // autofocus doesn't work - it's replacement + return AlertDialog( + title: Text('Enter ARL'.i18n), + content: TextField( + onChanged: (String s) => _arl = s, + decoration: InputDecoration( + labelText: 'Token (ARL)'.i18n), + focusNode: focusNode, + controller: controller, + onSubmitted: (String s) { + goARL(focusNode, controller); + }, + ), + actions: [ + TextButton( + child: Text('Save'.i18n), + onPressed: () => goARL(null, controller), + ) + ], + ); + }); + }, + ), + ), + Container( + height: 16.0, + ), + Text( + "If you don't have account, you can register on deezer.com for free." + .i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: OutlinedButton( + child: Text('Open in browser'.i18n), + onPressed: () { + InAppBrowser.openWithSystemBrowser( + url: WebUri('https://deezer.com/register')); + }, + ), + ), + Container( + height: 8.0, + ), + const Divider(), + Container( + height: 8.0, + ), + Text( + "By using this app, you don't agree with the Deezer ToS".i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ) + ], + ), + ), + ); + } + return Container(); + } +} + +class LoginBrowser extends StatelessWidget { + final Function updateParent; + const LoginBrowser(this.updateParent, {super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: InAppWebView( + initialUrlRequest: + URLRequest(url: WebUri('https://deezer.com/login')), + onLoadStart: + (InAppWebViewController controller, WebUri? loadedUri) async { + //Offers URL + if (!loadedUri!.path.contains('/login') && + !loadedUri.path.contains('/register')) { + controller.evaluateJavascript( + source: 'window.location.href = "/open_app"'); + } + + //Parse arl from url + if (loadedUri + .toString() + .startsWith('intent://deezer.page.link')) { + try { + //Actual url is in `link` query parameter + Uri linkUri = Uri.parse(loadedUri.queryParameters['link']!); + String? arl = linkUri.queryParameters['arl']; + settings.arl = arl; + // Clear cookies for next login after logout + CookieManager.instance().deleteAllCookies(); + Navigator.of(context).pop(); + updateParent(); + } catch (e) { + Logger.root + .severe('Error loading ARL from browser login: $e'); + } + } + }, + ), + ), + ], + ); + } +} + +class EmailLogin extends StatefulWidget { + final Function callback; + const EmailLogin(this.callback, {super.key}); + + @override + _EmailLoginState createState() => _EmailLoginState(); +} + +class _EmailLoginState extends State { + String? _email; + String? _password; + bool _loading = false; + + Future _login() async { + setState(() => _loading = true); + //Try logging in + String? arl; + String? exception; + try { + arl = await DeezerLogin.getArlByEmailAndPassword(_email!, _password!); + } on DeezerLoginException catch (dle) { + exception = dle.toString(); + } catch (e, st) { + exception = e.toString(); + if (kDebugMode) { + print(e); + print(st); + } + } + setState(() => _loading = false); + settings.arl = arl; + if (mounted) Navigator.of(context).pop(); + + if (exception == null) { + //Success + widget.callback(); + return; + } else if (mounted) { + //Error + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Error logging in!'.i18n), + content: Text( + 'Error logging in using email, please check your credentials.\n\nError: ${exception!}'), + actions: [ + TextButton( + child: Text('Dismiss'.i18n), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + )); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Email Login'.i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: _loading + ? [const CircularProgressIndicator()] + : [ + TextField( + decoration: InputDecoration(labelText: 'Email'.i18n), + onChanged: (s) => _email = s, + ), + Container( + height: 8.0, + ), + TextField( + obscureText: true, + decoration: InputDecoration(labelText: 'Password'.i18n), + onChanged: (s) => _password = s, + ) + ], + ), + actions: [ + if (!_loading) + TextButton( + child: const Text('Login'), + onPressed: () async { + if (_email != null && _password != null) { + await _login(); + } else { + Fluttertoast.showToast( + msg: 'Missing email or password!'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + } + }, + ) + ], + ); + } +} diff --git a/lib/ui/lyrics.dart b/lib/ui/lyrics.dart new file mode 100644 index 0000000..b791052 --- /dev/null +++ b/lib/ui/lyrics.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../service/audio_service.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../ui/elements.dart'; +import '../ui/error.dart'; + +class LyricsScreen extends StatefulWidget { + final Lyrics? lyrics; + final String trackId; + + const LyricsScreen({this.lyrics, required this.trackId, super.key}); + + @override + _LyricsScreenState createState() => _LyricsScreenState(); +} + +class _LyricsScreenState extends State { + String appBarTitle = 'Lyrics'.i18n; + Lyrics? lyrics; + bool _loading = true; + bool _error = false; + int _currentIndex = 0; + int _prevIndex = 0; + Timer? _timer; + final ScrollController _controller = ScrollController(); + StreamSubscription? _mediaItemSub; + final double height = 90; + + @override + void initState() { + super.initState(); + _load(); + + //Enable visualizer + if (settings.lyricsVisualizer) { + GetIt.I().startVisualizer(); + } + + //Track change = exit lyrics + _mediaItemSub = GetIt.I().mediaItem.listen((event) { + if (event?.id != widget.trackId) Navigator.of(context).pop(); + }); + } + + Future _load() async { + if (widget.lyrics?.isLoaded() == true) { + _updateLyricsState(widget.lyrics!); + return; + } + + try { + Lyrics l = await deezerAPI.lyrics(widget.trackId); + _updateLyricsState(l); + } catch (e) { + _timer?.cancel(); + setState(() { + _error = true; + _loading = false; + }); + } + } + + void _updateLyricsState(Lyrics lyrics) { + String screenTitle = 'Lyrics'.i18n; + + if (lyrics.isSynced()) { + _startSyncTimer(); + } else if ((lyrics.isUnsynced())) { + screenTitle = 'Unsynchronized lyrics'.i18n; + _timer?.cancel(); + } + + if (lyrics.errorMessage != null) { + Logger.root.warning( + 'Error loading lyrics for track id ${widget.trackId}: ${lyrics.errorMessage}'); + } + + setState(() { + appBarTitle = screenTitle; + this.lyrics = lyrics; + _loading = false; + _error = false; + }); + } + + void _startSyncTimer() { + Timer.periodic(const Duration(milliseconds: 350), (timer) { + _timer = timer; + if (_loading) return; + + //Update current lyric index + setState(() => _currentIndex = lyrics!.syncedLyrics!.lastIndexWhere( + (lyric) => + (lyric.offset ?? const Duration(seconds: 0)) <= + GetIt.I().playbackState.value.position)); + + //Scroll to current lyric + if (_currentIndex <= 0) return; + if (_prevIndex == _currentIndex) return; + _prevIndex = _currentIndex; + _controller.animateTo( + //Lyric height, screen height, appbar height + (height * _currentIndex) - + (MediaQuery.of(context).size.height / 2) + + (height / 2) + + 56, + duration: const Duration(milliseconds: 250), + curve: Curves.ease); + }); + } + + @override + void dispose() { + _timer?.cancel(); + _mediaItemSub?.cancel(); + _controller.dispose(); + //Stop visualizer + if (settings.lyricsVisualizer) { + GetIt.I().stopVisualizer(); + } + //Fix bottom buttons + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: settings.themeData.scaffoldBackgroundColor, + )); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar(appBarTitle), + body: Stack( + children: [ + //Visualizer + if (settings.lyricsVisualizer) + Align( + alignment: Alignment.bottomCenter, + child: StreamBuilder( + stream: GetIt.I().visualizerStream, + builder: (BuildContext context, AsyncSnapshot snapshot) { + List data = snapshot.data ?? []; + double width = + MediaQuery.of(context).size.width / data.length - + 0.25; + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate( + data.length, + (i) => AnimatedContainer( + duration: const Duration(milliseconds: 130), + color: Theme.of(context).primaryColor, + height: data[i] * 100, + width: width, + )), + ); + }), + ), + + //Lyrics + Padding( + padding: EdgeInsets.fromLTRB( + 0, 0, 0, settings.lyricsVisualizer ? 100 : 0), + child: ListView( + controller: _controller, + children: [ + //Shouldn't really happen, empty lyrics have own text + if (_error) const ErrorScreen(), + + //Loading + if (_loading) + const Padding( + padding: EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ), + + // Synced Lyrics + if (lyrics != null && + lyrics!.syncedLyrics?.isNotEmpty == true) + ...List.generate(lyrics!.syncedLyrics!.length, (i) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + color: (_currentIndex == i) + ? Colors.grey.withOpacity(0.25) + : Colors.transparent, + ), + height: height, + child: Center( + child: Text( + lyrics!.syncedLyrics![i].text ?? '', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26.0, + fontWeight: (_currentIndex == i) + ? FontWeight.bold + : FontWeight.normal), + ), + ))); + }), + + // Unsynced Lyrics + if (lyrics != null && + (lyrics!.syncedLyrics?.isEmpty ?? true) && + lyrics!.unsyncedLyrics != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + ), + child: Center( + child: Text( + lyrics!.unsyncedLyrics!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 26.0, + ), + ), + ), + ), + ), + ], + ), + ) + ], + )); + } +} diff --git a/lib/ui/menu.dart b/lib/ui/menu.dart new file mode 100644 index 0000000..8979a1c --- /dev/null +++ b/lib/ui/menu.dart @@ -0,0 +1,929 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:get_it/get_it.dart'; +import 'package:numberpicker/numberpicker.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../api/cache.dart'; +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../api/download.dart'; +import '../utils/navigator_keys.dart'; +import '../service/audio_service.dart'; +import '../translations.i18n.dart'; +import '../ui/cached_image.dart'; +import '../ui/details_screens.dart'; +import '../ui/error.dart'; + +class MenuSheet { + Function navigateCallback; + + // Use no-op callback if not provided + MenuSheet({Function? navigateCallback}) + : navigateCallback = navigateCallback ?? (() {}); + + //=================== + // DEFAULT + //=================== + + void show(BuildContext context, List options) { + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + (MediaQuery.of(context).orientation == Orientation.landscape) + ? 220 + : 350, + ), + child: SingleChildScrollView( + child: Column(children: options), + ), + ); + }); + } + + //=================== + // TRACK + //=================== + + void showWithTrack(BuildContext context, Track track, List options) { + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 16.0, + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Semantics( + label: 'Album art'.i18n, + image: true, + child: CachedImage( + url: track.albumArt?.full ?? '', + height: 128, + width: 128, + ), + ), + SizedBox( + width: 240.0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + track.title ?? '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 22.0, fontWeight: FontWeight.bold), + ), + Text( + track.artistString ?? '', + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle(fontSize: 20.0), + ), + Container( + height: 8.0, + ), + Text( + track.album?.title ?? '', + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text(track.durationString ?? '') + ], + ), + ), + ], + ), + Container( + height: 16.0, + ), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: (MediaQuery.of(context).orientation == + Orientation.landscape) + ? 200 + : 350, + ), + child: SingleChildScrollView( + child: Column(children: options), + ), + ) + ], + ); + }); + } + + //Default track options + void defaultTrackMenu(Track track, + {required BuildContext context, + List options = const [], + Function? onRemove}) { + showWithTrack(context, track, [ + addToQueueNext(track, context), + addToQueue(track, context), + (cache.checkTrackFavorite(track)) + ? removeFavoriteTrack(track, context, onUpdate: onRemove) + : addTrackFavorite(track, context), + addToPlaylist(track, context), + downloadTrack(track, context), + offlineTrack(track, context), + shareTile('track', track.id ?? ''), + playMix(track, context), + showAlbum(track.album!, context), + ...List.generate(track.artists?.length ?? 0, + (i) => showArtist(track.artists![i], context)), + ...options + ]); + } + + //=================== + // TRACK OPTIONS + //=================== + + Widget addToQueueNext(Track t, BuildContext context) => ListTile( + title: Text('Play next'.i18n), + leading: const Icon(Icons.playlist_play), + onTap: () async { + //-1 = next + await GetIt.I() + .insertQueueItem(-1, t.toMediaItem()); + if (context.mounted) _close(context); + }); + + Widget addToQueue(Track t, BuildContext context) => ListTile( + title: Text('Add to queue'.i18n), + leading: const Icon(Icons.playlist_add), + onTap: () async { + await GetIt.I().addQueueItem(t.toMediaItem()); + if (context.mounted) _close(context); + }); + + Widget addTrackFavorite(Track t, BuildContext context) => ListTile( + title: Text('Add track to favorites'.i18n), + leading: const Icon(Icons.favorite), + onTap: () async { + await deezerAPI.addFavoriteTrack(t.id!); + //Make track offline, if favorites are offline + Playlist p = Playlist(id: deezerAPI.favoritesPlaylistId); + if (await downloadManager.checkOffline(playlist: p)) { + downloadManager.addOfflinePlaylist(p); + } + Fluttertoast.showToast( + msg: 'Added to library'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + //Add to cache + cache.libraryTracks ??= []; + cache.libraryTracks?.add(t.id!); + + if (context.mounted) _close(context); + }); + + Widget downloadTrack(Track t, BuildContext context) => ListTile( + title: Text('Download'.i18n), + leading: const Icon(Icons.file_download), + onTap: () async { + if (await downloadManager.addOfflineTrack(t, + private: false, isSingleton: true) != + false) { + showDownloadStartedToast(); + } + if (context.mounted) _close(context); + }, + ); + + Widget addToPlaylist(Track t, BuildContext context) => ListTile( + title: Text('Add to playlist'.i18n), + leading: const Icon(Icons.playlist_add), + onTap: () async { + //Show dialog to pick playlist + await showDialog( + 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, + ); + }); + }); + if (context.mounted) _close(context); + }, + ); + + Widget removeFromPlaylist(Track t, Playlist p, BuildContext context) => + ListTile( + title: Text('Remove from playlist'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + await deezerAPI.removeFromPlaylist(t.id!, p.id!); + Fluttertoast.showToast( + msg: 'Track removed from'.i18n + ' ${p.title}', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + ); + if (context.mounted) _close(context); + }, + ); + + Widget removeFavoriteTrack(Track t, BuildContext context, {onUpdate}) => + ListTile( + title: Text('Remove favorite'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + await deezerAPI.removeFavorite(t.id!); + //Check if favorites playlist is offline, update it + Playlist p = Playlist(id: deezerAPI.favoritesPlaylistId); + if (await downloadManager.checkOffline(playlist: p)) { + await downloadManager.addOfflinePlaylist(p); + } + //Remove from cache + cache.libraryTracks?.removeWhere((i) => i == t.id); + Fluttertoast.showToast( + msg: 'Track removed from library'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + if (onUpdate != null) onUpdate(); + if (context.mounted) _close(context); + }, + ); + + //Redirect to artist page (ie from track) + Widget showArtist(Artist a, BuildContext context) => ListTile( + title: Text( + 'Go to'.i18n + ' ${a.name}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: const Icon(Icons.recent_actors), + onTap: () { + if (context.mounted) _close(context); + customNavigatorKey.currentState + ?.push(MaterialPageRoute(builder: (context) => ArtistDetails(a))); + + navigateCallback(); + }, + ); + + Widget showAlbum(Album a, BuildContext context) => ListTile( + title: Text( + 'Go to'.i18n + ' ${a.title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: const Icon(Icons.album), + onTap: () { + if (context.mounted) _close(context); + customNavigatorKey.currentState + ?.push(MaterialPageRoute(builder: (context) => AlbumDetails(a))); + + navigateCallback(); + }, + ); + + Widget playMix(Track track, BuildContext context) => ListTile( + title: Text('Play mix'.i18n), + leading: const Icon(Icons.online_prediction), + onTap: () async { + GetIt.I().playMix(track.id!, track.title!); + if (context.mounted) _close(context); + }, + ); + + Widget offlineTrack(Track track, BuildContext context) => FutureBuilder( + future: downloadManager.checkOffline(track: track), + builder: (innerContext, snapshot) { + bool isOffline = snapshot.data ?? (track.offline ?? false); + return ListTile( + title: Text(isOffline ? 'Remove offline'.i18n : 'Offline'.i18n), + leading: const Icon(Icons.offline_pin), + onTap: () async { + if (isOffline) { + await downloadManager.removeOfflineTracks([track]); + Fluttertoast.showToast( + msg: 'Track removed from offline!'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + } else { + await downloadManager.addOfflineTrack(track, private: true); + } + if (context.mounted) _close(context); + }, + ); + }, + ); + + //=================== + // ALBUM + //=================== + + //Default album options + void defaultAlbumMenu(Album album, + {required BuildContext context, + List options = const [], + Function? onRemove}) { + show(context, [ + (album.library != null && onRemove != null) + ? removeAlbum(album, context, onRemove: onRemove) + : libraryAlbum(album, context), + downloadAlbum(album, context), + offlineAlbum(album, context), + shareTile('album', album.id!), + ...options + ]); + } + + //=================== + // ALBUM OPTIONS + //=================== + + Widget downloadAlbum(Album a, BuildContext context) => ListTile( + title: Text('Download'.i18n), + leading: const Icon(Icons.file_download), + onTap: () async { + if (context.mounted) _close(context); + if (await downloadManager.addOfflineAlbum(a, private: false) != false) { + showDownloadStartedToast(); + } + }); + + Widget offlineAlbum(Album a, BuildContext context) => ListTile( + title: Text('Make offline'.i18n), + leading: const Icon(Icons.offline_pin), + onTap: () async { + await deezerAPI.addFavoriteAlbum(a.id!); + await downloadManager.addOfflineAlbum(a, private: true); + if (context.mounted) _close(context); + showDownloadStartedToast(); + }, + ); + + Widget libraryAlbum(Album a, BuildContext context) => ListTile( + title: Text('Add to library'.i18n), + leading: const Icon(Icons.library_music), + onTap: () async { + await deezerAPI.addFavoriteAlbum(a.id!); + Fluttertoast.showToast( + msg: 'Added to library'.i18n, gravity: ToastGravity.BOTTOM); + if (context.mounted) _close(context); + }, + ); + + //Remove album from favorites + Widget removeAlbum(Album a, BuildContext context, + {required Function onRemove}) => + ListTile( + title: Text('Remove album'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + await deezerAPI.removeAlbum(a.id!); + await downloadManager.removeOfflineAlbum(a.id!); + Fluttertoast.showToast( + msg: 'Album removed'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + ); + onRemove(); + if (context.mounted) _close(context); + }, + ); + + //=================== + // ARTIST + //=================== + + void defaultArtistMenu(Artist artist, + {required BuildContext context, + List options = const [], + Function? onRemove}) { + show(context, [ + (artist.library != null) + ? removeArtist(artist, context, onRemove: onRemove) + : favoriteArtist(artist, context), + shareTile('artist', artist.id!), + ...options + ]); + } + + //=================== + // ARTIST OPTIONS + //=================== + + Widget removeArtist(Artist a, BuildContext context, {Function? onRemove}) => + ListTile( + title: Text('Remove from favorites'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + await deezerAPI.removeArtist(a.id!); + Fluttertoast.showToast( + msg: 'Artist removed from library'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + if (onRemove != null) onRemove(); + if (context.mounted) _close(context); + }, + ); + + Widget favoriteArtist(Artist a, BuildContext context) => ListTile( + title: Text('Add to favorites'.i18n), + leading: const Icon(Icons.favorite), + onTap: () async { + await deezerAPI.addFavoriteArtist(a.id!); + Fluttertoast.showToast( + msg: 'Added to library'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + if (context.mounted) _close(context); + }, + ); + + //=================== + // PLAYLIST + //=================== + + void defaultPlaylistMenu(Playlist playlist, + {required BuildContext context, + List options = const [], + Function? onRemove, + Function? onUpdate}) { + show(context, [ + (playlist.library != null) + ? removePlaylistLibrary(playlist, context, onRemove: onRemove) + : addPlaylistLibrary(playlist, context), + addPlaylistOffline(playlist, context), + downloadPlaylist(playlist, context), + shareTile('playlist', playlist.id!), + if (playlist.user?.id == deezerAPI.userId) + editPlaylist(playlist, context: context, onUpdate: onUpdate), + ...options + ]); + } + + //=================== + // PLAYLIST OPTIONS + //=================== + + Widget removePlaylistLibrary(Playlist p, BuildContext context, + {Function? onRemove}) => + ListTile( + title: Text('Remove from library'.i18n), + leading: const Icon(Icons.delete), + onTap: () async { + if (p.user?.id?.trim() == deezerAPI.userId) { + //Delete playlist if own + await deezerAPI.deletePlaylist(p.id!); + } else { + //Just remove from library + await deezerAPI.removePlaylist(p.id!); + } + downloadManager.removeOfflinePlaylist(p.id!); + if (onRemove != null) onRemove(); + if (context.mounted) _close(context); + }, + ); + + Widget addPlaylistLibrary(Playlist p, BuildContext context) => ListTile( + title: Text('Add playlist to library'.i18n), + leading: const Icon(Icons.favorite), + onTap: () async { + await deezerAPI.addPlaylist(p.id!); + Fluttertoast.showToast( + msg: 'Added playlist to library'.i18n, + gravity: ToastGravity.BOTTOM); + if (context.mounted) _close(context); + }, + ); + + Widget addPlaylistOffline(Playlist p, BuildContext context) => ListTile( + title: Text('Make playlist offline'.i18n), + leading: const Icon(Icons.offline_pin), + onTap: () async { + //Add to library + await deezerAPI.addPlaylist(p.id!); + downloadManager.addOfflinePlaylist(p, private: true); + if (context.mounted) _close(context); + showDownloadStartedToast(); + }, + ); + + Widget downloadPlaylist(Playlist p, BuildContext context) => ListTile( + title: Text('Download playlist'.i18n), + leading: const Icon(Icons.file_download), + onTap: () async { + if (context.mounted) _close(context); + if (await downloadManager.addOfflinePlaylist(p, private: false) != + false) { + showDownloadStartedToast(); + } + }, + ); + + Widget editPlaylist(Playlist p, + {required BuildContext context, Function? onUpdate}) => + ListTile( + title: Text('Edit playlist'.i18n), + leading: const Icon(Icons.edit), + onTap: () async { + await showDialog( + context: context, + builder: (context) => CreatePlaylistDialog(playlist: p)); + if (context.mounted) _close(context); + if (onUpdate != null) onUpdate(); + }, + ); + + //=================== + // SHOW/EPISODE + //=================== + + defaultShowEpisodeMenu(Show s, ShowEpisode e, + {required BuildContext context, List options = const []}) { + show(context, [ + shareTile('episode', e.id!), + shareShow(s.id!), + downloadExternalEpisode(e), + ...options + ]); + } + + Widget shareShow(String id) => ListTile( + title: Text('Share show'.i18n), + leading: const Icon(Icons.share), + onTap: () async { + Share.share('https://deezer.com/show/$id'); + }, + ); + + //Open direct download link in browser + Widget downloadExternalEpisode(ShowEpisode e) => ListTile( + title: Text('Download externally'.i18n), + leading: const Icon(Icons.file_download), + onTap: () async { + if (e.url != null) await launchUrlString(e.url!); + }, + ); + + //=================== + // OTHER + //=================== + + showDownloadStartedToast() { + Fluttertoast.showToast( + msg: 'Downloads added!'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + } + + //Create playlist + Future createPlaylist(BuildContext context) async { + await showDialog( + context: context, + builder: (BuildContext context) { + return const CreatePlaylistDialog(); + }); + } + + Widget shareTile(String type, String id) => ListTile( + title: Text('Share'.i18n), + leading: const Icon(Icons.share), + onTap: () async { + Share.share('https://deezer.com/$type/$id'); + }, + ); + + Widget sleepTimer(BuildContext context) => ListTile( + title: Text('Sleep timer'.i18n), + leading: const Icon(Icons.access_time), + onTap: () async { + showDialog( + context: context, + builder: (context) { + return const SleepTimerDialog(); + }); + }, + ); + + Widget wakelock(BuildContext context) => ListTile( + title: Text('Keep the screen on'.i18n), + leading: const Icon(Icons.screen_lock_portrait), + onTap: () async { + _close(context); + //Enable + if (!cache.wakelock) { + WakelockPlus.enable(); + Fluttertoast.showToast( + msg: 'Wakelock enabled!'.i18n, gravity: ToastGravity.BOTTOM); + cache.wakelock = true; + return; + } + //Disable + WakelockPlus.disable(); + Fluttertoast.showToast( + msg: 'Wakelock disabled!'.i18n, gravity: ToastGravity.BOTTOM); + cache.wakelock = false; + }, + ); + + void _close(BuildContext context) => Navigator.of(context).pop(); +} + +class SleepTimerDialog extends StatefulWidget { + const SleepTimerDialog({super.key}); + + @override + _SleepTimerDialogState createState() => _SleepTimerDialogState(); +} + +class _SleepTimerDialogState extends State { + int hours = 0; + int minutes = 30; + + String _endTime() { + return '${cache.sleepTimerTime!.hour.toString().padLeft(2, '0')}:${cache.sleepTimerTime!.minute.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Sleep timer'.i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Hours:'.i18n), + NumberPicker( + value: hours, + minValue: 0, + maxValue: 69, + onChanged: (v) => setState(() => hours = v)), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Minutes:'.i18n), + NumberPicker( + value: minutes, + minValue: 0, + maxValue: 60, + onChanged: (v) => setState(() => minutes = v)), + ], + ), + ], + ), + Container(height: 4.0), + if (cache.sleepTimerTime != null) + Text( + 'Current timer ends at'.i18n + ': ' + _endTime(), + textAlign: TextAlign.center, + ) + ], + ), + actions: [ + TextButton( + child: Text('Dismiss'.i18n), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + if (cache.sleepTimer != null) + TextButton( + child: Text('Cancel current timer'.i18n), + onPressed: () { + cache.sleepTimer!.cancel(); + cache.sleepTimer = null; + cache.sleepTimerTime = null; + Navigator.of(context).pop(); + }, + ), + TextButton( + child: Text('Save'.i18n), + onPressed: () { + Duration duration = Duration(hours: hours, minutes: minutes); + cache.sleepTimer?.cancel(); + //Create timer + cache.sleepTimer = + Stream.fromFuture(Future.delayed(duration)).listen((_) { + GetIt.I().pause(); + cache.sleepTimer?.cancel(); + cache.sleepTimerTime = null; + cache.sleepTimer = null; + }); + cache.sleepTimerTime = DateTime.now().add(duration); + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} + +class SelectPlaylistDialog extends StatefulWidget { + final Track? track; + final Function callback; + const SelectPlaylistDialog({this.track, required this.callback, super.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 const CreatePlaylistDialog(); + } + return CreatePlaylistDialog(tracks: [widget.track!]); + } + + return AlertDialog( + title: Text('Select playlist'.i18n), + content: FutureBuilder( + future: deezerAPI.getPlaylists(), + builder: (context, snapshot) { + if (snapshot.hasError) { + const SizedBox( + height: 100, + child: ErrorScreen(), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return const 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: () { + widget.callback(playlists[i]); + Navigator.of(context).pop(); + }, + )), + ListTile( + title: Text('Create new playlist'.i18n), + leading: const Icon(Icons.add), + onTap: () async { + setState(() { + createNew = true; + }); + }, + ) + ]), + ); + }, + ), + ); + } +} + +class CreatePlaylistDialog extends StatefulWidget { + final List? tracks; + //If playlist not null, update + final Playlist? playlist; + const CreatePlaylistDialog({this.tracks, this.playlist, super.key}); + + @override + _CreatePlaylistDialogState createState() => _CreatePlaylistDialogState(); +} + +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) { + _title = widget.playlist?.title ?? ''; + _description = widget.playlist?.description ?? ''; + } + + _titleController = TextEditingController(text: _title); + _descController = TextEditingController(text: _description); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(edit ? 'Edit playlist'.i18n : 'Create playlist'.i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + 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), + ), + Container( + height: 4.0, + ), + DropdownButton( + value: _playlistType, + onChanged: (int? v) { + setState(() => _playlistType = v!); + }, + items: [ + DropdownMenuItem( + value: 1, + child: Text('Private'.i18n), + ), + DropdownMenuItem( + value: 2, + child: Text('Collaborative'.i18n), + ), + ], + ), + ], + ), + actions: [ + TextButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text(edit ? 'Update'.i18n : 'Create'.i18n), + onPressed: () async { + if (edit) { + //Update + await deezerAPI.updatePlaylist(widget.playlist!.id!, + _titleController!.value.text, _descController!.value.text, + status: _playlistType); + Fluttertoast.showToast( + msg: 'Playlist updated!'.i18n, gravity: ToastGravity.BOTTOM); + } else { + List tracks = []; + 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); + } + if (context.mounted) Navigator.of(context).pop(); + }, + ) + ], + ); + } +} diff --git a/lib/ui/player_bar.dart b/lib/ui/player_bar.dart new file mode 100644 index 0000000..37b2e69 --- /dev/null +++ b/lib/ui/player_bar.dart @@ -0,0 +1,294 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get_it/get_it.dart'; + +import '../service/audio_service.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../ui/router.dart'; +import 'cached_image.dart'; +import 'player_screen.dart'; + +class PlayerBar extends StatefulWidget { + const PlayerBar({super.key}); + + @override + _PlayerBarState createState() => _PlayerBarState(); +} + +class _PlayerBarState extends State { + final double iconSize = 28; + //bool _gestureRegistered = false; + + double get _progress { + if (GetIt.I().playbackState.value.processingState == AudioProcessingState.idle) return 0.0; + if (GetIt.I().mediaItem.value == null) return 0.0; + if (GetIt.I().mediaItem.value!.duration!.inSeconds == 0) return 0.0; //Division by 0 + return GetIt.I().playbackState.value.position.inSeconds / + GetIt.I().mediaItem.value!.duration!.inSeconds; + } + + @override + Widget build(BuildContext context) { + var focusNode = FocusNode(); + return GestureDetector( + key: UniqueKey(), + /* Old swipe detection, seems less efficient... + onHorizontalDragUpdate: (details) async { + if (_gestureRegistered) return; + const double sensitivity = 12.69; + //Right swipe + _gestureRegistered = true; + if (details.delta.dx > sensitivity) { + await GetIt.I().skipToPrevious(); + } + //Left + if (details.delta.dx < -sensitivity) { + await GetIt.I().skipToNext(); + } + _gestureRegistered = false; + return; + }, + onVerticalDragUpdate: (DragUpdateDetails details) { + if (details.delta.dy < 8) { + Navigator.of(context).push(SlideBottomRoute(widget: PlayerScreen())); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: + settings.themeData.scaffoldBackgroundColor, + )); + } + },*/ + onHorizontalDragEnd: (DragEndDetails details) async { + if ((details.primaryVelocity ?? 0) < -100) { + // Swiped left + await GetIt.I().skipToPrevious(); + } else if ((details.primaryVelocity ?? 0) > 100) { + // Swiped right + await GetIt.I().skipToNext(); + } + }, + onVerticalDragEnd: (DragEndDetails details) async { + if ((details.primaryVelocity ?? 0) < -100) { + // Swiped up + Navigator.of(context).push(SlideBottomRoute(widget: const PlayerScreen())); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: settings.themeData.scaffoldBackgroundColor, + )); + } /*else if ((details.primaryVelocity ?? 0) > 100) { + // Swiped down => no action + }*/ + }, + child: StreamBuilder( + stream: Stream.periodic(const Duration(milliseconds: 250)), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (GetIt.I().mediaItem.value == null) { + return const SizedBox( + width: 0, + height: 0, + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + // For Android TV: indicate focus by grey + color: focusNode.hasFocus ? Colors.black26 : Theme.of(context).bottomAppBarTheme.color, + child: ListTile( + dense: true, + focusNode: focusNode, + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + onTap: () { + Navigator.of(context).push(SlideBottomRoute(widget: const PlayerScreen())); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: settings.themeData.scaffoldBackgroundColor, + )); + }, + leading: CachedImage( + width: 50, + height: 50, + url: GetIt.I().mediaItem.value?.extras?['thumb'] ?? + GetIt.I().mediaItem.value?.artUri, + ), + title: Text( + GetIt.I().mediaItem.value?.displayTitle ?? '', + overflow: TextOverflow.clip, + maxLines: 1, + ), + subtitle: Text( + GetIt.I().mediaItem.value?.displaySubtitle ?? '', + overflow: TextOverflow.clip, + maxLines: 1, + ), + trailing: IconTheme( + data: IconThemeData(color: settings.isDark ? Colors.white : Colors.grey[600]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PrevNextButton( + iconSize, + prev: true, + hidePrev: true, + ), + PlayPauseButton(iconSize), + PrevNextButton(iconSize) + ], + ), + )), + ), + SizedBox( + height: 3.0, + child: LinearProgressIndicator( + backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), + value: _progress, + ), + ) + ], + ); + }), + ); + } +} + +class PrevNextButton extends StatelessWidget { + final double size; + final bool prev; + final bool hidePrev; + + const PrevNextButton(this.size, {super.key, this.prev = false, this.hidePrev = false}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: GetIt.I().queueStateStream, + builder: (context, snapshot) { + final queueState = snapshot.data; + if (!prev) { + if (!(queueState?.hasNext ?? false)) { + return IconButton( + icon: Icon( + Icons.skip_next, + semanticLabel: 'Play next'.i18n, + ), + iconSize: size, + onPressed: null, + ); + } + return IconButton( + icon: Icon( + Icons.skip_next, + semanticLabel: 'Play next'.i18n, + ), + iconSize: size, + onPressed: () => GetIt.I().skipToNext(), + ); + } + if (prev) { + if (!(queueState?.hasPrevious ?? false)) { + if (hidePrev) { + return const SizedBox( + height: 0, + width: 0, + ); + } + return IconButton( + icon: Icon( + Icons.skip_previous, + semanticLabel: 'Play previous'.i18n, + ), + iconSize: size, + onPressed: null, + ); + } + return IconButton( + icon: Icon( + Icons.skip_previous, + semanticLabel: 'Play previous'.i18n, + ), + iconSize: size, + onPressed: () => GetIt.I().skipToPrevious(), + ); + } + return Container(); + }, + ); + } +} + +class PlayPauseButton extends StatefulWidget { + final double size; + const PlayPauseButton(this.size, {super.key}); + + @override + _PlayPauseButtonState createState() => _PlayPauseButtonState(); +} + +class _PlayPauseButtonState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 200)); + _animation = Tween(begin: 0, end: 1).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: GetIt.I().playbackState, + builder: (context, snapshot) { + final playbackState = GetIt.I().playbackState.value; + final playing = playbackState.playing; + final processingState = playbackState.processingState; + + // Animated icon by pato05 + // Morph from pause to play or from play to pause + if (playing || processingState == AudioProcessingState.ready || processingState == AudioProcessingState.idle) { + if (playing) { + _controller.forward(); + } else { + _controller.reverse(); + } + + return IconButton( + splashRadius: widget.size, + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _animation, + semanticLabel: playing ? 'Pause'.i18n : 'Play'.i18n, + ), + iconSize: widget.size, + onPressed: + playing ? () => GetIt.I().pause() : () => GetIt.I().play()); + } + + switch (processingState) { + //Loading, connecting, rewinding... + case AudioProcessingState.buffering: + case AudioProcessingState.loading: + return SizedBox( + width: widget.size * 0.85, + height: widget.size * 0.85, + child: Center( + child: Transform.scale( + scale: 0.85, // Adjust the scale to 75% of the original size + child: const CircularProgressIndicator(), + ), + ), + ); + //Stopped/Error + default: + return SizedBox(width: widget.size, height: widget.size); + } + }, + ); + } +} diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart new file mode 100644 index 0000000..e486287 --- /dev/null +++ b/lib/ui/player_screen.dart @@ -0,0 +1,1198 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui'; + +import 'package:async/async.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:get_it/get_it.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:marquee/marquee.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../api/cache.dart'; +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../api/download.dart'; +import '../fonts/refreezer_icons.dart'; +import '../service/audio_service.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import 'cached_image.dart'; +import 'elements.dart'; +import 'lyrics.dart'; +import 'menu.dart'; +import 'player_bar.dart'; +import 'router.dart'; +import 'settings_screen.dart'; +import 'tiles.dart'; + +//So can be updated when going back from lyrics +late Function updateColor; +late Color scaffoldBackgroundColor; + +class PlayerScreen extends StatefulWidget { + const PlayerScreen({super.key}); + + @override + _PlayerScreenState createState() => _PlayerScreenState(); +} + +class _PlayerScreenState extends State { + AudioPlayerHandler audioHandler = GetIt.I(); + LinearGradient? _bgGradient; + StreamSubscription? _mediaItemSub; + ImageProvider? _blurImage; + + //Calculate background color + Future _updateColor() async { + if (!settings.colorGradientBackground && !settings.blurPlayerBackground) { + return; + } + + //BG Image + if (settings.blurPlayerBackground) { + setState(() { + _blurImage = NetworkImage( + audioHandler.mediaItem.value?.extras?['thumb'] ?? + audioHandler.mediaItem.value?.artUri); + }); + } + + //Run in isolate + PaletteGenerator palette = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider( + audioHandler.mediaItem.value?.extras?['thumb'] ?? + audioHandler.mediaItem.value?.artUri)); + + //Update notification + if (settings.blurPlayerBackground) { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: palette.dominantColor!.color.withOpacity(0.25), + systemNavigationBarColor: Color.alphaBlend( + palette.dominantColor!.color.withOpacity(0.25), + scaffoldBackgroundColor))); + } + + //Color gradient + if (!settings.blurPlayerBackground) { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: palette.dominantColor!.color.withOpacity(0.7), + )); + setState(() => _bgGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + palette.dominantColor!.color.withOpacity(0.7), + const Color.fromARGB(0, 0, 0, 0) + ], + stops: const [ + 0.0, + 0.6 + ])); + } + } + + @override + void initState() { + //Future.delayed(Duration(milliseconds: 600), _updateColor); + _updateColor; + _mediaItemSub = audioHandler.mediaItem.listen((event) { + _updateColor(); + }); + + updateColor = _updateColor; + super.initState(); + } + + @override + void dispose() { + _mediaItemSub?.cancel(); + //Fix bottom buttons + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: settings.themeData.bottomAppBarTheme.color, + statusBarColor: Colors.transparent)); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + //Responsive + ScreenUtil.init(context, minTextAdapt: true); + //Avoid async gap + scaffoldBackgroundColor = Theme.of(context).scaffoldBackgroundColor; + + return Scaffold( + body: SafeArea( + child: Container( + decoration: BoxDecoration( + gradient: + settings.blurPlayerBackground ? null : _bgGradient), + child: Stack( + children: [ + if (settings.blurPlayerBackground) + ClipRect( + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: _blurImage ?? const NetworkImage(''), + fit: BoxFit.fill, + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.25), + BlendMode.dstATop))), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container(color: Colors.transparent), + ), + ), + ), + StreamBuilder( + stream: StreamZip( + [audioHandler.playbackState, audioHandler.mediaItem]), + builder: (BuildContext context, AsyncSnapshot snapshot) { + //When disconnected + if (audioHandler.mediaItem.value == null) { + //playerHelper.startService(); + return const Center( + child: CircularProgressIndicator(), + ); + } + + return OrientationBuilder( + builder: (context, orientation) { + //Landscape + if (orientation == Orientation.landscape) { + // ignore: prefer_const_constructors + return PlayerScreenHorizontal(); + } + //Portrait + // ignore: prefer_const_constructors + return PlayerScreenVertical(); + }, + ); + }, + ), + ], + )))); + } +} + +//Landscape +class PlayerScreenHorizontal extends StatefulWidget { + const PlayerScreenHorizontal({super.key}); + + @override + _PlayerScreenHorizontalState createState() => _PlayerScreenHorizontalState(); +} + +class _PlayerScreenHorizontalState extends State { + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 2), + child: SizedBox( + width: ScreenUtil().setWidth(160), + child: const Stack( + children: [ + BigAlbumArt(), + ], + ), + ), + ), + //Right side + SizedBox( + width: ScreenUtil().setWidth(170), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), + child: PlayerScreenTopRow( + textSize: ScreenUtil().setSp(28), + iconSize: ScreenUtil().setSp(38), + textWidth: ScreenUtil().setWidth(150), + short: false)), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: ScreenUtil().setSp(50), + child: GetIt.I() + .mediaItem + .value! + .displayTitle! + .length >= + 22 + ? Marquee( + text: GetIt.I() + .mediaItem + .value! + .displayTitle!, + style: TextStyle( + fontSize: ScreenUtil().setSp(40), + fontWeight: FontWeight.bold), + blankSpace: 32.0, + startPadding: 10.0, + accelerationDuration: const Duration(seconds: 1), + pauseAfterRound: const Duration(seconds: 2), + ) + : Text( + GetIt.I() + .mediaItem + .value! + .displayTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: ScreenUtil().setSp(40), + fontWeight: FontWeight.bold), + )), + Container( + height: 4, + ), + Text( + GetIt.I() + .mediaItem + .value! + .displaySubtitle ?? + '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: ScreenUtil().setSp(32), + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: const SeekBar(24.0), + ), + PlaybackControls(ScreenUtil().setSp(60)), + Padding( + //padding: EdgeInsets.fromLTRB(4, 0, 4, 8), + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + //Icons.subtitles, + ReFreezerIcons.lyrics_mic, + size: ScreenUtil().setWidth(12), + semanticLabel: 'Lyrics'.i18n, + ), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => LyricsScreen( + trackId: GetIt.I() + .mediaItem + .value! + .id))); + }, + ), + IconButton( + icon: Icon( + Icons.file_download, + size: ScreenUtil().setWidth(12), + semanticLabel: 'Download'.i18n, + ), + onPressed: () async { + Track t = Track.fromMediaItem( + GetIt.I().mediaItem.value!); + if (await downloadManager.addOfflineTrack(t, + private: false, isSingleton: true) != + false) { + Fluttertoast.showToast( + msg: 'Downloads added!'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + } + }, + ), + const QualityInfoWidget(), + RepeatButton(ScreenUtil().setWidth(12)), + const PlayerMenuButton() + ], + ), + )) + ], + ), + ) + ], + ); + } +} + +//Portrait +class PlayerScreenVertical extends StatefulWidget { + const PlayerScreenVertical({super.key}); + + @override + _PlayerScreenVerticalState createState() => _PlayerScreenVerticalState(); +} + +class _PlayerScreenVerticalState extends State { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 16, 0), + child: PlayerScreenTopRow( + textSize: ScreenUtil().setSp(14), + iconSize: ScreenUtil().setSp(18), + textWidth: ScreenUtil().setWidth(350), + short: true)), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: SizedBox( + height: ScreenUtil().setHeight(360), + child: const Stack( + children: [ + BigAlbumArt(), + ], + ), + ), + ), + Container(height: 4.0), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: ScreenUtil().setSp(26), + child: (GetIt.I() + .mediaItem + .value + ?.displayTitle ?? + '') + .length >= + 26 + ? Marquee( + text: GetIt.I() + .mediaItem + .value + ?.displayTitle ?? + '', + style: TextStyle( + fontSize: ScreenUtil().setSp(22), + fontWeight: FontWeight.bold), + blankSpace: 32.0, + startPadding: 10.0, + accelerationDuration: const Duration(seconds: 1), + pauseAfterRound: const Duration(seconds: 2), + ) + : Text( + GetIt.I() + .mediaItem + .value + ?.displayTitle ?? + '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: ScreenUtil().setSp(22), + fontWeight: FontWeight.bold), + )), + Container( + height: 4, + ), + Text( + GetIt.I().mediaItem.value?.displaySubtitle ?? + '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: ScreenUtil().setSp(16), + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + const SeekBar(12.0), + PlaybackControls(ScreenUtil().setSp(36)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + /*IconButton( + icon: Icon( + //Icons.lyrics, + ReFreezerIcons.lyrics_mic, + size: ScreenUtil().setWidth(20), + semanticLabel: 'Lyrics'.i18n, + ), + onPressed: () async { + //Fix bottom buttons + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent)); + + await Navigator.of(context).push(MaterialPageRoute( + builder: (context) => LyricsScreen( + trackId: GetIt.I() + .mediaItem + .value! + .id))); + + updateColor(); + }, + ),*/ + LyricsIconButton(20, afterOnPressed: updateColor), + IconButton( + icon: Icon( + Icons.file_download, + size: ScreenUtil().setWidth(20), + semanticLabel: 'Download'.i18n, + ), + onPressed: () async { + Track t = Track.fromMediaItem( + GetIt.I().mediaItem.value!); + if (await downloadManager.addOfflineTrack(t, + private: false, isSingleton: true) != + false) { + Fluttertoast.showToast( + msg: 'Downloads added!'.i18n, + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT); + } + }, + ), + const QualityInfoWidget(), + RepeatButton(ScreenUtil().setWidth(20)), + const PlayerMenuButton() + ], + ), + ) + ], + ); + } +} + +class QualityInfoWidget extends StatefulWidget { + const QualityInfoWidget({super.key}); + + @override + _QualityInfoWidgetState createState() => _QualityInfoWidgetState(); +} + +class _QualityInfoWidgetState extends State { + AudioPlayerHandler audioHandler = GetIt.I(); + String value = ''; + StreamSubscription? streamSubscription; + + //Load data from native + void _load() async { + if (audioHandler.mediaItem.value == null) return; + Map? data = await DownloadManager.platform.invokeMethod( + 'getStreamInfo', {'id': audioHandler.mediaItem.value!.id}); + //N/A + if (data == null) { + if (mounted) setState(() => value = ''); + //If not shown, try again later + if (audioHandler.mediaItem.value?.extras?['show'] == null) { + Future.delayed(const Duration(milliseconds: 200), _load); + } + + return; + } + //Update + StreamQualityInfo info = StreamQualityInfo.fromJson(data); + if (mounted) { + setState(() { + value = + '${info.format} ${info.bitrate(audioHandler.mediaItem.value!.duration ?? const Duration(seconds: 0))}kbps'; + }); + } + } + + @override + void initState() { + _load(); + streamSubscription ??= audioHandler.mediaItem.listen((event) async { + _load(); + }); + super.initState(); + } + + @override + void dispose() { + streamSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (value != '') { + return TextButton( + child: Text(value), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const QualitySettings())); + }, + ); + } + return Container(); + /*return Center( + child: Transform.scale( + scale: 0.75, // Adjust the scale to 75% of the original size + child: const CircularProgressIndicator(), + ), + );*/ + } +} + +class LyricsIconButton extends StatelessWidget { + final double width; + final Function? afterOnPressed; + + const LyricsIconButton( + this.width, { + super.key, + this.afterOnPressed, + }); + + @override + Widget build(BuildContext context) { + Track track = + Track.fromMediaItem(GetIt.I().mediaItem.value!); + + bool isEnabled = (track.lyrics?.id ?? '0') != '0'; + + return Opacity( + opacity: isEnabled + ? 1.0 + : 0.7, // Full opacity for enabled, reduced for disabled + child: IconButton( + icon: Icon( + //Icons.lyrics, + ReFreezerIcons.lyrics_mic, + size: ScreenUtil().setWidth(width), + semanticLabel: 'Lyrics'.i18n, + ), + onPressed: isEnabled + ? () async { + //Fix bottom buttons + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent)); + + await Navigator.of(context).push(MaterialPageRoute( + builder: (context) => LyricsScreen(trackId: track.id!))); + + if (afterOnPressed != null) { + afterOnPressed!(); + } + } + : null, // No action when disabled + ), + ); + } +} + +class PlayerMenuButton extends StatelessWidget { + const PlayerMenuButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon( + //Icons.more_vert, + Icons.menu, + size: ScreenUtil().setWidth(12), + semanticLabel: 'Options'.i18n, + ), + onPressed: () { + Track t = + Track.fromMediaItem(GetIt.I().mediaItem.value!); + MenuSheet m = MenuSheet(navigateCallback: () { + Navigator.of(context).pop(); + }); + if (GetIt.I().mediaItem.value!.extras?['show'] == + null) { + m.defaultTrackMenu(t, + context: context, + options: [m.sleepTimer(context), m.wakelock(context)]); + } else { + m.defaultShowEpisodeMenu( + Show.fromJson(jsonDecode(GetIt.I() + .mediaItem + .value! + .extras?['show'])), + ShowEpisode.fromMediaItem( + GetIt.I().mediaItem.value!), + context: context, + options: [m.sleepTimer(context), m.wakelock(context)]); + } + }, + ); + } +} + +class RepeatButton extends StatefulWidget { + final double iconSize; + const RepeatButton(this.iconSize, {super.key}); + + @override + _RepeatButtonState createState() => _RepeatButtonState(); +} + +class _RepeatButtonState extends State { + Icon get repeatIcon { + switch (GetIt.I().getLoopMode()) { + case LoopMode.off: + return Icon( + Icons.repeat, + size: widget.iconSize, + semanticLabel: 'Repeat off'.i18n, + ); + case LoopMode.all: + return Icon( + Icons.repeat, + color: Theme.of(context).primaryColor, + size: widget.iconSize, + semanticLabel: 'Repeat'.i18n, + ); + case LoopMode.one: + return Icon( + Icons.repeat_one, + color: Theme.of(context).primaryColor, + size: widget.iconSize, + semanticLabel: 'Repeat one'.i18n, + ); + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: repeatIcon, + onPressed: () async { + await GetIt.I().changeRepeat(); + setState(() {}); + }, + ); + } +} + +class PlaybackControls extends StatefulWidget { + final double iconSize; + const PlaybackControls(this.iconSize, {super.key}); + + @override + _PlaybackControlsState createState() => _PlaybackControlsState(); +} + +class _PlaybackControlsState extends State { + AudioPlayerHandler audioHandler = GetIt.I(); + Icon get libraryIcon { + if (cache.checkTrackFavorite( + Track.fromMediaItem(audioHandler.mediaItem.value!))) { + return Icon( + Icons.favorite, + size: widget.iconSize * 0.44, + semanticLabel: 'Unlove'.i18n, + ); + } + return Icon( + Icons.favorite_border, + size: widget.iconSize * 0.44, + semanticLabel: 'Love'.i18n, + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + icon: Icon( + Icons.sentiment_very_dissatisfied, + size: widget.iconSize * 0.44, + semanticLabel: 'Dislike'.i18n, + ), + onPressed: () async { + await deezerAPI.dislikeTrack(audioHandler.mediaItem.value!.id); + if (audioHandler.queueState.hasNext) { + audioHandler.skipToNext(); + } + }), + PrevNextButton(widget.iconSize, prev: true), + PlayPauseButton(widget.iconSize * 1.25), + PrevNextButton(widget.iconSize), + IconButton( + icon: libraryIcon, + onPressed: () async { + cache.libraryTracks ??= []; + + if (cache.checkTrackFavorite( + Track.fromMediaItem(audioHandler.mediaItem.value!))) { + //Remove from library + setState(() => cache.libraryTracks + ?.remove(audioHandler.mediaItem.value!.id)); + await deezerAPI + .removeFavorite(audioHandler.mediaItem.value!.id); + await cache.save(); + } else { + //Add + setState(() => + cache.libraryTracks?.add(audioHandler.mediaItem.value!.id)); + await deezerAPI + .addFavoriteTrack(audioHandler.mediaItem.value!.id); + await cache.save(); + } + }, + ) + ], + ), + ); + } +} + +class BigAlbumArt extends StatefulWidget { + const BigAlbumArt({super.key}); + + @override + _BigAlbumArtState createState() => _BigAlbumArtState(); +} + +class _BigAlbumArtState extends State with WidgetsBindingObserver { + final AudioPlayerHandler audioHandler = GetIt.I(); + List _imageList = []; + late PageController _pageController; + StreamSubscription? _currentItemAndQueueSub; + bool _isVisible = false; + bool _changeTrackOnPageChange = true; + + @override + void initState() { + super.initState(); + _pageController = PageController( + initialPage: audioHandler.currentIndex, + ); + + _imageList = _getImageList(audioHandler.queue.value); + + _currentItemAndQueueSub = + Rx.combineLatest2, void>( + audioHandler.mediaItem, + audioHandler.queue, + (mediaItem, queue) { + if (queue.isNotEmpty) { + _handleMediaItemChange(mediaItem); + if (_didQueueChange(queue)) { + setState(() { + _imageList = _getImageList(queue); + }); + } + } + }, + ).listen((_) {}); + + WidgetsBinding.instance.addObserver(this); + } + + List _getImageList(List queue) { + return queue + .map((item) => ZoomableImage(url: item.artUri?.toString() ?? '')) + .toList(); + } + + bool _didQueueChange(List newQueue) { + if (newQueue.length != _imageList.length) { + // Length changed = new queue + return true; + } + for (int i = 0; i < newQueue.length; i++) { + if (newQueue[i].artUri?.toString() != _imageList[i].url) { + // An item changed on this position = new queue + return true; + } + } + // No changes = same queue + return false; + } + + void _handleMediaItemChange(MediaItem? item) async { + final targetItemId = item?.id ?? ''; + final targetPage = + audioHandler.queue.value.indexWhere((item) => item.id == targetItemId); + if (targetPage == -1) return; + + // No need to animating to the same page + if (_pageController.page?.round() == targetPage) return; + + if (_isVisible) { + // Widget is visible, animate to the target page + _changeTrackOnPageChange = false; + await _pageController + .animateToPage( + targetPage, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ) + .then((_) { + _changeTrackOnPageChange = true; + }); + } else { + // Widget is not visible, jump to the target page without animation + _changeTrackOnPageChange = false; + _pageController.jumpToPage(targetPage); + _changeTrackOnPageChange = true; + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _currentItemAndQueueSub?.cancel(); + _pageController.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + setState(() { + _isVisible = state == AppLifecycleState.resumed; + }); + } + + @override + Widget build(BuildContext context) { + return VisibilityDetector( + key: const Key('big_album_art'), + onVisibilityChanged: (VisibilityInfo info) { + if (mounted) { + setState(() { + _isVisible = info.visibleFraction > 0.0; + }); + } + }, + child: GestureDetector( + onVerticalDragUpdate: (DragUpdateDetails details) { + if (details.delta.dy > 16) { + Navigator.of(context).pop(); + } + }, + child: PageView( + controller: _pageController, + onPageChanged: (int index) { + if (_changeTrackOnPageChange) { + // Only trigger if the page change is caused by user swiping + audioHandler.skipToQueueItem(index); + } + }, + children: _imageList, + ), + ), + ); + } +} + +//Top row containing QueueSource, queue... +class PlayerScreenTopRow extends StatelessWidget { + final double? textSize; + final double? iconSize; + final double? textWidth; + final bool? short; + final GlobalKey iconButtonKey = GlobalKey(); + PlayerScreenTopRow( + {super.key, this.textSize, this.iconSize, this.textWidth, this.short}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon( + Icons.keyboard_arrow_down_sharp, + ), + iconSize: iconSize ?? ScreenUtil().setSp(52), + splashRadius: iconSize ?? ScreenUtil().setWidth(52), + onPressed: () async { + // Navigate back + Navigator.pop(context); + }, + ), + Expanded( + child: SizedBox( + width: textWidth ?? ScreenUtil().setWidth(800), + child: Text( + (short ?? false) + ? (GetIt.I().queueSource?.text ?? '') + : 'Playing from:'.i18n + + ' ' + + (GetIt.I().queueSource?.text ?? ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: TextStyle(fontSize: textSize ?? ScreenUtil().setSp(16)), + ), + ), + ), + IconButton( + key: iconButtonKey, + icon: Icon( + //Icons.menu, + Icons.queue_music, + semanticLabel: 'Queue'.i18n, + ), + iconSize: iconSize ?? ScreenUtil().setSp(52), + splashRadius: iconSize ?? ScreenUtil().setWidth(52), + onPressed: () async { + //Fix bottom buttons (Not needed anymore?) + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle(statusBarColor: Colors.transparent)); + + // Calculate the center of the icon + final RenderBox buttonRenderBox = + iconButtonKey.currentContext!.findRenderObject() as RenderBox; + final Offset buttonOffset = buttonRenderBox + .localToGlobal(buttonRenderBox.size.center(Offset.zero)); + //Navigate + //await Navigator.of(context).push(MaterialPageRoute(builder: (context) => QueueScreen())); + await Navigator.of(context).push(CircularExpansionRoute( + widget: const QueueScreen(), + //centerAlignment: Alignment.topRight, + centerOffset: buttonOffset)); // Expand from icon + //Fix colors + updateColor(); + }, + ), + ], + ); + } +} + +class SeekBar extends StatefulWidget { + final double relativeTextSize; + const SeekBar(this.relativeTextSize, {super.key}); + + @override + _SeekBarState createState() => _SeekBarState(); +} + +class _SeekBarState extends State { + AudioPlayerHandler audioHandler = GetIt.I(); + bool _seeking = false; + double _pos = 0; + + double get position { + if (_seeking) return _pos; + double p = + audioHandler.playbackState.value.position.inMilliseconds.toDouble(); + if (p > duration) return duration; + return p; + } + + //Duration to mm:ss + String _timeString(double pos) { + Duration d = Duration(milliseconds: pos.toInt()); + return "${d.inMinutes}:${d.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + } + + double get duration { + if (audioHandler.mediaItem.value == null) return 1.0; + return audioHandler.mediaItem.value!.duration!.inMilliseconds.toDouble(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: Stream.periodic(const Duration(milliseconds: 250)), + builder: (BuildContext context, AsyncSnapshot snapshot) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 0.0, horizontal: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _timeString(position), + style: TextStyle( + fontSize: ScreenUtil().setSp(widget.relativeTextSize)), + ), + Text( + _timeString(duration), + style: TextStyle( + fontSize: ScreenUtil().setSp(widget.relativeTextSize)), + ) + ], + ), + ), + SizedBox( + height: 32.0, + child: Slider( + focusNode: FocusNode( + canRequestFocus: false, + skipTraversal: + true), // Don't focus on Slider - it doesn't work (and not needed) + value: position, + max: duration, + onChangeStart: (double d) { + setState(() { + _seeking = true; + _pos = d; + }); + }, + onChanged: (double d) { + setState(() { + _pos = d; + }); + }, + onChangeEnd: (double d) async { + await audioHandler.seek(Duration(milliseconds: d.round())); + setState(() { + _pos = d; + _seeking = false; + }); + }, + ), + ) + ], + ); + }, + ); + } +} + +class QueueScreen extends StatefulWidget { + const QueueScreen({super.key}); + + @override + _QueueScreenState createState() => _QueueScreenState(); +} + +class _QueueScreenState extends State { + AudioPlayerHandler audioHandler = GetIt.I(); + late StreamSubscription _queueStateSub; + + @override + void initState() { + super.initState(); + _queueStateSub = audioHandler.queueStateStream.listen((queueState) { + setState(() {}); + }); + } + + @override + void dispose() { + _queueStateSub.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final queueState = audioHandler.queueState; + final shuffleModeEnabled = + queueState.shuffleMode == AudioServiceShuffleMode.all; + + return Scaffold( + appBar: FreezerAppBar( + 'Queue'.i18n, + actions: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 16, 0), + child: IconButton( + icon: Icon( + Icons.shuffle, + semanticLabel: 'Shuffle'.i18n, + color: + shuffleModeEnabled ? Theme.of(context).primaryColor : null, + ), + onPressed: () async { + await audioHandler.toggleShuffle(); + }, + ), + ) + ], + ), + body: shuffleModeEnabled // No manual re-ordring in shuffle mode + ? ListView.builder( + itemCount: queueState.queue.length, + itemBuilder: (context, index) { + final mediaItem = queueState.queue[index]; + final track = Track.fromMediaItem(mediaItem); + return TrackTile( + track, + onTap: () async { + await audioHandler.skipToQueueItem(index); + if (context.mounted) Navigator.of(context).pop(); + }, + key: Key(mediaItem.id), + trailing: IconButton( + icon: Icon( + Icons.close, + semanticLabel: 'Close'.i18n, + ), + onPressed: () async { + await audioHandler.removeQueueItem(mediaItem); + }, + ), + ); + }, + ) + : ReorderableListView.builder( + itemCount: queueState.queue.length, + onReorder: (int oldIndex, int newIndex) async { + // Circumvent bug in ReorderableListView that won't be fixed: https://github.com/flutter/flutter/pull/93146#issuecomment-1032082749 + if (newIndex > oldIndex) newIndex -= 1; + if (oldIndex == newIndex) return; + await audioHandler.moveQueueItem(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final mediaItem = queueState.queue[index]; + final track = Track.fromMediaItem(mediaItem); + return TrackTile( + track, + onTap: () async { + await audioHandler.skipToQueueItem(index); + if (context.mounted) Navigator.of(context).pop(); + }, + key: Key(mediaItem.id), + trailing: IconButton( + icon: Icon( + Icons.close, + semanticLabel: 'Close'.i18n, + ), + onPressed: () async { + await audioHandler.removeQueueItem(mediaItem); + }, + ), + ); + }, + ), + ); + } +} diff --git a/lib/ui/restartable.dart b/lib/ui/restartable.dart new file mode 100644 index 0000000..e9c3db6 --- /dev/null +++ b/lib/ui/restartable.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; + +import '../utils/navigator_keys.dart'; + +/// Wrap the root App widget with this widget and call [Restartable.restart] to simulate app restart. +/// This restarts the application at the application level, rebuilding the application widget tree from scratch, losing any previous state. +/// It won't fully restart the application process at the OS level. +class Restartable extends StatefulWidget { + final Widget child; + + const Restartable({super.key, required this.child}); + + @override + _RestartableState createState() => _RestartableState(); + + static restart() { + mainNavigatorKey.currentContext!.findAncestorStateOfType<_RestartableState>()!.restartApp(); + } +} + +class _RestartableState extends State { + Key _key = UniqueKey(); + + void restartApp() { + setState(() { + _key = UniqueKey(); + }); + } + + @override + Widget build(BuildContext context) { + return KeyedSubtree( + key: _key, + child: widget.child, + ); + } +} diff --git a/lib/ui/router.dart b/lib/ui/router.dart new file mode 100644 index 0000000..5f6c536 --- /dev/null +++ b/lib/ui/router.dart @@ -0,0 +1,202 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +// Slide left to right +class SlideLeftRoute extends PageRouteBuilder { + final Widget widget; + SlideLeftRoute({required this.widget}) + : super(pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return widget; + }, transitionsBuilder: + (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return SlideTransition( + position: Tween( + begin: const Offset(-1.0, 0.0), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }); +} + +// Slide right to left +class SlideRightRoute extends PageRouteBuilder { + final Widget widget; + SlideRightRoute({required this.widget}) + : super(pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return widget; + }, transitionsBuilder: + (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return SlideTransition( + position: Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }); +} + +// Slide top to bottom +class SlideTopRoute extends PageRouteBuilder { + final Widget widget; + SlideTopRoute({required this.widget}) + : super(pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return widget; + }, transitionsBuilder: + (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return SlideTransition( + position: Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }); +} + +// Slide top to bottom +class SlideTopRightRoute extends PageRouteBuilder { + final Widget widget; + SlideTopRightRoute({required this.widget}) + : super(pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return widget; + }, transitionsBuilder: + (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return SlideTransition( + position: Tween( + begin: const Offset(1.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }); +} + +// Slide bottom to top +class SlideBottomRoute extends PageRouteBuilder { + final Widget widget; + SlideBottomRoute({required this.widget}) + : super(pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return widget; + }, transitionsBuilder: + (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return SlideTransition( + position: Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ); + // transitionDuration:Duration(seconds: 1); + }); +} + +// Scale in and out animation +class ScaleRoute extends PageRouteBuilder { + final Widget widget; + + ScaleRoute({required this.widget}) + : super(pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return widget; + }, transitionsBuilder: + (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return ScaleTransition( + scale: Tween( + begin: 0.0, + end: 1.0, + ).animate( + CurvedAnimation( + parent: animation, + curve: const Interval( + 0.00, + 0.50, + curve: Curves.linear, + ), + ), + ), + child: ScaleTransition( + scale: Tween( + begin: 1.5, + end: 1.0, + ).animate( + CurvedAnimation( + parent: animation, + curve: const Interval( + 0.50, + 1.00, + curve: Curves.linear, + ), + ), + ), + child: child, + ), + ); + }); +} + +// Expand out as circle from given centerAlignment or centerOffset (screen center is used if omitted) +class CircularExpansionRoute extends PageRouteBuilder { + final Widget widget; + final Alignment? centerAlignment; + final Offset? centerOffset; + + CircularExpansionRoute({required this.widget, this.centerAlignment, this.centerOffset}) + : super( + pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return widget; + }, + transitionsBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return ClipPath( + clipper: CircularRevealClipper( + fraction: animation.value, + centerAlignment: centerAlignment, + centerOffset: centerOffset, + ), + child: child, + ); + }, + ); +} + +class CircularRevealClipper extends CustomClipper { + final double fraction; + final Alignment? centerAlignment; + final Offset? centerOffset; + + CircularRevealClipper({ + required this.fraction, + this.centerAlignment, + this.centerOffset, + }); + + @override + Path getClip(Size size) { + final Offset center = + centerAlignment?.alongSize(size) ?? centerOffset ?? Offset(size.width / 2, size.height / 2); + + return Path() + ..addOval( + Rect.fromCircle(center: center, radius: lerpDouble(0, _calcMaxOvalRadius(size, center), fraction)!), + ); + } + + @override + bool shouldReclip(CustomClipper oldClipper) => true; + + // Calculates the maximum radius of an oval that can fit inside a rectangle with the given size and center + // by finding the maximum horizontal and vertical radii of the oval + // and then calculating the maximum radius using the Pythagorean theorem. + static double _calcMaxOvalRadius(Size size, Offset center) { + final w = max(center.dx, size.width - center.dx); + final h = max(center.dy, size.height - center.dy); + return sqrt(w * w + h * h); + } +} diff --git a/lib/ui/search.dart b/lib/ui/search.dart new file mode 100644 index 0000000..1c85425 --- /dev/null +++ b/lib/ui/search.dart @@ -0,0 +1,1017 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttericon/font_awesome5_icons.dart'; +import 'package:fluttericon/typicons_icons.dart'; +import 'package:get_it/get_it.dart'; + +import '../api/cache.dart'; +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../api/download.dart'; +import '../service/audio_service.dart'; +import '../translations.i18n.dart'; +import '../ui/details_screens.dart'; +import '../ui/elements.dart'; +import '../ui/home_screen.dart'; +import '../ui/menu.dart'; +import '../utils/navigator_keys.dart'; +import './error.dart'; +import './tiles.dart'; +import 'downloads_screen.dart'; +import 'settings_screen.dart'; + +openScreenByURL(String url) async { + DeezerLinkResponse? res = await deezerAPI.parseLink(url); + if (res == null || res.type == null) return; + + switch (res.type!) { + case DeezerLinkType.TRACK: + Track t = await deezerAPI.track(res.id!); + MenuSheet() + .defaultTrackMenu(t, context: mainNavigatorKey.currentContext!); + break; + case DeezerLinkType.ALBUM: + Album a = await deezerAPI.album(res.id!); + mainNavigatorKey.currentState + ?.push(MaterialPageRoute(builder: (context) => AlbumDetails(a))); + break; + case DeezerLinkType.ARTIST: + Artist a = await deezerAPI.artist(res.id!); + mainNavigatorKey.currentState + ?.push(MaterialPageRoute(builder: (context) => ArtistDetails(a))); + break; + case DeezerLinkType.PLAYLIST: + Playlist p = await deezerAPI.playlist(res.id!); + mainNavigatorKey.currentState + ?.push(MaterialPageRoute(builder: (context) => PlaylistDetails(p))); + break; + } +} + +class SearchScreen extends StatefulWidget { + const SearchScreen({super.key}); + + @override + _SearchScreenState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + String? _query; + bool _offline = false; + bool _loading = false; + final TextEditingController _controller = TextEditingController(); + FocusNode _keyboardListenerFocusNode = FocusNode(); + FocusNode _textFieldFocusNode = FocusNode(); + List _suggestions = []; + bool _cancel = false; + bool _showCards = true; + + void _submit(BuildContext context, {String? query}) async { + if (query != null) { + _query = query; + } + + //URL + if (_query != null && _query!.startsWith('http')) { + setState(() => _loading = true); + try { + await openScreenByURL(_query!); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + setState(() => _loading = false); + return; + } + + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => SearchResultsScreen( + _query ?? '', + offline: _offline, + ))); + } + + @override + void initState() { + _cancel = false; + //Check for connectivity and enable offline mode + Connectivity().checkConnectivity().then((res) { + if (res.isEmpty || res.contains(ConnectivityResult.none)) { + setState(() { + _offline = true; + }); + } + }); + + super.initState(); + } + + //Load search suggestions + Future _loadSuggestions() async { + if (_query == null || _query!.length < 2 || _query!.startsWith('http')) { + return null; + } + String q = _query!; + await Future.delayed(const Duration(milliseconds: 300)); + if (q != _query) return null; + //Load + late List sugg; + try { + sugg = await deezerAPI.searchSuggestions(_query!); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + + if (!_cancel) setState(() => _suggestions = sugg); + return sugg; + } + + Widget _removeHistoryItemWidget(int index) { + return IconButton( + icon: Icon( + Icons.close, + semanticLabel: 'Remove'.i18n, + ), + onPressed: () async { + cache.searchHistory?.removeAt(index); + setState(() {}); + await cache.save(); + }); + } + + @override + void dispose() { + _cancel = true; + _textFieldFocusNode.dispose(); + _keyboardListenerFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'Search'.i18n, + actions: [ + IconButton( + icon: Icon( + Icons.file_download, + semanticLabel: 'Download'.i18n, + ), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const DownloadsScreen())); + }, + ), + IconButton( + icon: Icon( + Icons.settings, + semanticLabel: 'Settings'.i18n, + ), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const SettingsScreen())); + }, + ), + ], + ), + body: FocusScope( + child: ListView( + children: [ + Container(height: 4.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: Stack( + alignment: const Alignment(1.0, 0.0), + children: [ + KeyboardListener( + focusNode: _keyboardListenerFocusNode = FocusNode(), + onKeyEvent: (event) { + // For Android TV: quit search textfield + /*if (event is KeyUpEvent) { + if (event.logicalKey == + LogicalKeyboardKey.arrowDown) { + _textFieldFocusNode.unfocus(); + } + }*/ + }, + child: TextField( + onChanged: (String s) { + setState(() { + _showCards = false; + _query = s; + }); + _loadSuggestions(); + }, + onTap: () { + setState(() => _showCards = false); + }, + focusNode: _textFieldFocusNode = FocusNode(), + decoration: InputDecoration( + labelText: 'Search or paste URL'.i18n, + fillColor: + Theme.of(context).bottomAppBarTheme.color, + filled: true, + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey)), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey)), + ), + controller: _controller, + onSubmitted: (String s) { + _submit(context, query: s); + _textFieldFocusNode.unfocus(); + }, + ), + ), + Focus( + canRequestFocus: + false, // Focus is moving to cross, and hangs out there, + descendantsAreFocusable: + false, // so we disable focusing on it at all + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 40.0, + child: IconButton( + splashRadius: 20.0, + icon: Icon( + Icons.clear, + semanticLabel: 'Clear'.i18n, + ), + onPressed: () { + setState(() { + _suggestions = []; + _query = ''; + }); + _controller.clear(); + }, + ), + ), + ], + )) + ], + )), + ], + ), + ), + Container(height: 8.0), + ListTile( + title: Text('Offline search'.i18n), + leading: const Icon(Icons.offline_pin), + trailing: Switch( + value: _offline, + onChanged: (v) { + setState(() => _offline = !_offline); + }, + ), + ), + if (_loading) const LinearProgressIndicator(), + const FreezerDivider(), + + //"Browse" Cards + if (_showCards) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Text( + 'Quick access', + style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + SearchBrowseCard( + color: const Color(0xff11b192), + text: 'Flow'.i18n, + icon: const Icon(Typicons.waves), + onTap: () async { + // No channel for Flow... + await GetIt.I() + .playFromSmartTrackList(SmartTrackList(id: 'flow')); + }, + ), + SearchBrowseCard( + color: const Color(0xff7c42bb), + text: 'Shows'.i18n, + icon: const Icon(FontAwesome5.podcast), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Scaffold( + appBar: FreezerAppBar('Shows'.i18n), + body: SingleChildScrollView( + child: HomePageScreen( + channel: DeezerChannel(target: 'shows'))), + ), + )), + ) + ], + ), + Container(height: 4.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + SearchBrowseCard( + color: const Color(0xffff555d), + icon: const Icon(FontAwesome5.chart_line), + text: 'Charts'.i18n, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Scaffold( + appBar: FreezerAppBar('Charts'.i18n), + body: SingleChildScrollView( + child: HomePageScreen( + channel: + DeezerChannel(target: 'channels/charts'))), + ), + )), + ), + SearchBrowseCard( + color: const Color(0xff2c4ea7), + text: 'Browse'.i18n, + icon: Image.asset('assets/browse_icon.png', width: 26.0), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Scaffold( + appBar: FreezerAppBar('Browse'.i18n), + body: SingleChildScrollView( + child: HomePageScreen( + channel: + DeezerChannel(target: 'channels/explore'))), + ), + )), + ) + ], + ) + ], + + //History + if (!_showCards && + (cache.searchHistory?.length ?? 0) > 0 && + (_query ?? '').length < 2) + ...List.generate( + cache.searchHistory!.length > 10 + ? 10 + : cache.searchHistory!.length, (int i) { + dynamic data = cache.searchHistory![i].data; + switch (cache.searchHistory![i].type) { + case SearchHistoryItemType.TRACK: + return TrackTile( + data, + onTap: () { + List queue = cache.searchHistory! + .where((h) => h.type == SearchHistoryItemType.TRACK) + .map((t) => t.data) + .toList(); + GetIt.I().playFromTrackList( + queue, + data.id, + QueueSource( + text: 'Search history'.i18n, + source: 'searchhistory', + id: 'searchhistory')); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(data, context: context); + }, + trailing: _removeHistoryItemWidget(i), + ); + case SearchHistoryItemType.ALBUM: + return AlbumTile( + data, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => AlbumDetails(data))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(data, context: context); + }, + trailing: _removeHistoryItemWidget(i), + ); + case SearchHistoryItemType.ARTIST: + return ArtistHorizontalTile( + data, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ArtistDetails(data))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultArtistMenu(data, context: context); + }, + trailing: _removeHistoryItemWidget(i), + ); + case SearchHistoryItemType.PLAYLIST: + return PlaylistTile( + data, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => PlaylistDetails(data))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultPlaylistMenu(data, context: context); + }, + trailing: _removeHistoryItemWidget(i), + ); + default: + return Container(); + } + }), + + //Clear history + if (cache.searchHistory != null && cache.searchHistory!.length > 2) + ListTile( + title: Text('Clear search history'.i18n), + leading: const Icon(Icons.clear_all), + onTap: () { + cache.searchHistory = []; + cache.save(); + setState(() {}); + }, + ), + + //Suggestions + ...List.generate( + _suggestions.length, + (i) => ListTile( + title: Text(_suggestions[i]), + leading: const Icon(Icons.search), + onTap: () { + setState(() => _query = _suggestions[i]); + _submit(context); + }, + )) + ], + ), + ), + ); + } +} + +class SearchBrowseCard extends StatelessWidget { + final Color color; + final Widget? icon; + final VoidCallback onTap; + final String text; + const SearchBrowseCard( + {super.key, + required this.color, + required this.onTap, + required this.text, + this.icon}); + + @override + Widget build(BuildContext context) { + return Card( + color: color, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: MediaQuery.of(context).size.width / 2 - 32, + height: 75, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) icon!, + if (icon != null) Container(width: 8.0), + Text( + text, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.bold, + color: (color.computeLuminance() > 0.5) + ? Colors.black + : Colors.white), + ), + ], + )), + ), + )); + } +} + +class SearchResultsScreen extends StatelessWidget { + final String query; + final bool? offline; + + const SearchResultsScreen(this.query, {super.key, this.offline}); + + Future _search() async { + if (offline ?? false) { + return await downloadManager.search(query); + } + return await deezerAPI.search(query); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Search Results'.i18n), + body: FutureBuilder( + future: _search(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError) return const ErrorScreen(); + + SearchResults results = snapshot.data; + + if (results.empty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning, + size: 64, + ), + Text('No results!'.i18n) + ], + ), + ); + } + + //Tracks + List tracks = []; + if (results.tracks != null && results.tracks!.isNotEmpty) { + tracks = [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 4.0), + child: Text( + 'Tracks'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + ...List.generate(3, (i) { + if (results.tracks!.length <= i) { + return const SizedBox( + width: 0, + height: 0, + ); + } + Track t = results.tracks![i]; + return TrackTile( + t, + onTap: () { + cache.addToSearchHistory(t); + GetIt.I().playFromTrackList( + results.tracks!, + t.id ?? '', + QueueSource( + text: 'Search'.i18n, + id: query, + source: 'search')); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(t, context: context); + }, + ); + }), + ListTile( + title: Text('Show all tracks'.i18n), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => TrackListScreen( + results.tracks!, + QueueSource( + id: query, + source: 'search', + text: 'Search'.i18n)))); + }, + ), + const FreezerDivider() + ]; + } + + //Albums + List albums = []; + if (results.albums != null && results.albums!.isNotEmpty) { + albums = [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 4.0), + child: Text( + 'Albums'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + ...List.generate(3, (i) { + if (results.albums!.length <= i) { + return const SizedBox( + height: 0, + width: 0, + ); + } + Album a = results.albums![i]; + return AlbumTile( + a, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(a, context: context); + }, + onTap: () { + cache.addToSearchHistory(a); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => AlbumDetails(a))); + }, + ); + }), + ListTile( + title: Text('Show all albums'.i18n), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => + AlbumListScreen(results.albums!))); + }, + ), + const FreezerDivider() + ]; + } + + //Artists + List artists = []; + if (results.artists != null && results.artists!.isNotEmpty) { + artists = [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Artists'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + Container(height: 4), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(results.artists!.length, (int i) { + Artist a = results.artists![i]; + return ArtistTile( + a, + onTap: () { + cache.addToSearchHistory(a); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ArtistDetails(a))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultArtistMenu(a, context: context); + }, + ); + }), + )), + const FreezerDivider() + ]; + } + + //Playlists + List playlists = []; + if (results.playlists != null && results.playlists!.isNotEmpty) { + playlists = [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Playlists'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + ...List.generate(3, (i) { + if (results.playlists!.length <= i) { + return const SizedBox( + height: 0, + width: 0, + ); + } + Playlist p = results.playlists![i]; + return PlaylistTile( + p, + onTap: () { + cache.addToSearchHistory(p); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => PlaylistDetails(p))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultPlaylistMenu(p, context: context); + }, + ); + }), + ListTile( + title: Text('Show all playlists'.i18n), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => + SearchResultPlaylists(results.playlists!))); + }, + ), + const FreezerDivider() + ]; + } + + //Shows + List shows = []; + if (results.shows != null && results.shows!.isNotEmpty) { + shows = [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Shows'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + ...List.generate(3, (i) { + if (results.shows!.length <= i) { + return const SizedBox( + height: 0, + width: 0, + ); + } + Show s = results.shows![i]; + return ShowTile( + s, + onTap: () async { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ShowScreen(s))); + }, + ); + }), + ListTile( + title: Text('Show all shows'.i18n), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ShowListScreen(results.shows!))); + }, + ), + const FreezerDivider() + ]; + } + + //Episodes + List episodes = []; + if (results.episodes != null && results.episodes!.isNotEmpty) { + episodes = [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Episodes'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + ...List.generate(3, (i) { + if (results.episodes!.length <= i) { + return const SizedBox( + height: 0, + width: 0, + ); + } + ShowEpisode e = results.episodes![i]; + return ShowEpisodeTile( + e, + trailing: IconButton( + icon: Icon( + Icons.more_vert, + semanticLabel: 'Options'.i18n, + ), + onPressed: () { + MenuSheet m = MenuSheet(); + m.defaultShowEpisodeMenu(e.show!, e, context: context); + }, + ), + onTap: () async { + //Load entire show, then play + List episodes = + await deezerAPI.allShowEpisodes(e.show!.id ?? ''); + await GetIt.I().playShowEpisode( + e.show!, episodes, + index: episodes.indexWhere((ep) => e.id == ep.id)); + }, + ); + }), + ListTile( + title: Text('Show all episodes'.i18n), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => + EpisodeListScreen(results.episodes!))); + }) + ]; + } + + return ListView( + children: [ + Container( + height: 8.0, + ), + ...tracks, + Container( + height: 8.0, + ), + ...albums, + Container( + height: 8.0, + ), + ...artists, + Container( + height: 8.0, + ), + ...playlists, + Container( + height: 8.0, + ), + ...shows, + Container( + height: 8.0, + ), + ...episodes + ], + ); + }, + )); + } +} + +//List all tracks +class TrackListScreen extends StatelessWidget { + final QueueSource queueSource; + final List tracks; + + const TrackListScreen(this.tracks, this.queueSource, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Tracks'.i18n), + body: ListView.builder( + itemCount: tracks.length, + itemBuilder: (BuildContext context, int i) { + Track t = tracks[i]; + return TrackTile( + t, + onTap: () { + GetIt.I() + .playFromTrackList(tracks, t.id ?? '', queueSource); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultTrackMenu(t, context: context); + }, + ); + }, + ), + ); + } +} + +//List all albums +class AlbumListScreen extends StatelessWidget { + final List albums; + const AlbumListScreen(this.albums, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Albums'.i18n), + body: ListView.builder( + itemCount: albums.length, + itemBuilder: (context, i) { + Album a = albums[i]; + return AlbumTile( + a, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AlbumDetails(a))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultAlbumMenu(a, context: context); + }, + ); + }, + ), + ); + } +} + +class SearchResultPlaylists extends StatelessWidget { + final List playlists; + const SearchResultPlaylists(this.playlists, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Playlists'.i18n), + body: ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, i) { + Playlist p = playlists[i]; + return PlaylistTile( + p, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => PlaylistDetails(p))); + }, + onHold: () { + MenuSheet m = MenuSheet(); + m.defaultPlaylistMenu(p, context: context); + }, + ); + }, + ), + ); + } +} + +class ShowListScreen extends StatelessWidget { + final List shows; + const ShowListScreen(this.shows, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Shows'.i18n), + body: ListView.builder( + itemCount: shows.length, + itemBuilder: (context, i) { + Show s = shows[i]; + return ShowTile( + s, + onTap: () { + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => ShowScreen(s))); + }, + ); + }, + ), + ); + } +} + +class EpisodeListScreen extends StatelessWidget { + final List episodes; + const EpisodeListScreen(this.episodes, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Episodes'.i18n), + body: ListView.builder( + itemCount: episodes.length, + itemBuilder: (context, i) { + ShowEpisode e = episodes[i]; + return ShowEpisodeTile( + e, + trailing: IconButton( + icon: Icon( + Icons.more_vert, + semanticLabel: 'Options'.i18n, + ), + onPressed: () { + MenuSheet m = MenuSheet(); + m.defaultShowEpisodeMenu(e.show!, e, context: context); + }, + ), + onTap: () async { + //Load entire show, then play + List episodes = + await deezerAPI.allShowEpisodes(e.show!.id ?? ''); + await GetIt.I().playShowEpisode( + e.show!, episodes, + index: episodes.indexWhere((ep) => e.id == ep.id)); + }, + ); + }, + )); + } +} diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart new file mode 100644 index 0000000..dea82ed --- /dev/null +++ b/lib/ui/settings_screen.dart @@ -0,0 +1,1853 @@ +import 'dart:io'; + +import 'package:clipboard/clipboard.dart'; +import 'package:country_currency_pickers/country.dart'; +import 'package:country_currency_pickers/country_picker_dialog.dart'; +import 'package:country_currency_pickers/utils/utils.dart'; +import 'package:disk_space_plus/disk_space_plus.dart'; +import 'package:external_path/external_path.dart'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_displaymode/flutter_displaymode.dart'; +//import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:flutter_material_color_picker/flutter_material_color_picker.dart'; +import 'package:fluttericon/font_awesome5_icons.dart'; +import 'package:fluttericon/web_symbols_icons.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:refreezer/ui/log_screen.dart'; +import 'package:scrobblenaut/scrobblenaut.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../api/cache.dart'; +import '../api/deezer.dart'; +import '../main.dart'; +import '../utils/env.dart'; +import '../utils/navigator_keys.dart'; +import '../service/audio_service.dart'; +import '../settings.dart'; +import '../translations.i18n.dart'; +import '../ui/downloads_screen.dart'; +import '../ui/elements.dart'; +import '../ui/error.dart'; +import '../ui/home_screen.dart'; +import '../ui/updater.dart'; +import '../utils/file_utils.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + _SettingsScreenState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Settings'.i18n), + body: ListView( + children: [ + ListTile( + title: Text('General'.i18n), + leading: + const LeadingIcon(Icons.settings, color: Color(0xffeca704)), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const GeneralSettings())), + ), + ListTile( + title: Text('Download Settings'.i18n), + leading: const LeadingIcon(Icons.cloud_download, + color: Color(0xffbe3266)), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const DownloadsSettings())), + ), + ListTile( + title: Text('Appearance'.i18n), + leading: + const LeadingIcon(Icons.color_lens, color: Color(0xff4b2e7e)), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AppearanceSettings())), + ), + ListTile( + title: Text('Quality'.i18n), + leading: + const LeadingIcon(Icons.high_quality, color: Color(0xff384697)), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const QualitySettings())), + ), + ListTile( + title: Text('Deezer'.i18n), + leading: + const LeadingIcon(Icons.equalizer, color: Color(0xff0880b5)), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DeezerSettings())), + ), + //Language select + ListTile( + title: Text('Language'.i18n), + leading: + const LeadingIcon(Icons.language, color: Color(0xff009a85)), + onTap: () { + showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text('Select language'.i18n), + children: List.generate(languages.length, (int i) { + Language l = languages[i]; + return ListTile( + title: Text(l.name), + subtitle: Text('${l.locale}-${l.country}'), + onTap: () async { + setState(() => + settings.language = '${l.locale}_${l.country}'); + await settings.save(); + showDialog( + context: mainNavigatorKey.currentContext!, + builder: (context) { + return AlertDialog( + title: Text('Language'.i18n), + content: Text( + 'Language changed, please restart ReFreezer to apply!' + .i18n), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + // Close the AlertDialog + Navigator.of(context).pop(); + // Close the SimpleDialog + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + }, + ); + }))); + }, + ), + ListTile( + title: Text('Updates'.i18n), + leading: const LeadingIcon(Icons.update, color: Color(0xff2ba766)), + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (context) => const UpdaterScreen())), + enabled: false, + ), + ListTile( + title: Text('About'.i18n), + leading: const LeadingIcon(Icons.info, color: Colors.grey), + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (context) => const CreditsScreen())), + ), + ], + ), + ); + } +} + +class AppearanceSettings extends StatefulWidget { + const AppearanceSettings({super.key}); + + @override + _AppearanceSettingsState createState() => _AppearanceSettingsState(); +} + +class _AppearanceSettingsState extends State { + ColorSwatch _swatch(int c) => ColorSwatch(c, {500: Color(c)}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Appearance'.i18n), + body: ListView( + children: [ + ListTile( + title: Text('Theme'.i18n), + subtitle: Text('Currently'.i18n + + ': ${settings.theme.toString().split('.').last}'), + leading: const Icon(Icons.color_lens), + onTap: () { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: Text('Select theme'.i18n), + children: [ + SimpleDialogOption( + child: Text('Light'.i18n), + onPressed: () { + setState(() => settings.theme = Themes.Light); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ), + SimpleDialogOption( + child: Text('Dark'.i18n), + onPressed: () { + setState(() => settings.theme = Themes.Dark); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ), + SimpleDialogOption( + child: Text('Black (AMOLED)'.i18n), + onPressed: () { + setState(() => settings.theme = Themes.Black); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ), + SimpleDialogOption( + child: Text('Deezer (Dark)'.i18n), + onPressed: () { + setState(() => settings.theme = Themes.Deezer); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ), + ], + ); + }); + }, + ), + ListTile( + title: Text('Use system theme'.i18n), + trailing: Switch( + value: settings.useSystemTheme, + onChanged: (bool v) async { + setState(() { + settings.useSystemTheme = v; + }); + updateTheme(); + await settings.save(); + }, + ), + leading: const Icon(Icons.android)), + ListTile( + title: Text('Font'.i18n), + leading: const Icon(Icons.font_download), + subtitle: Text(settings.font), + onTap: () { + showDialog( + context: context, + builder: (context) => + FontSelector(() => Navigator.of(context).pop())); + }, + ), + ListTile( + title: Text('Player gradient background'.i18n), + leading: const Icon(Icons.colorize), + trailing: Switch( + value: settings.colorGradientBackground, + onChanged: (bool v) async { + setState(() => settings.colorGradientBackground = v); + await settings.save(); + }, + ), + ), + ListTile( + title: Text('Blur player background'.i18n), + subtitle: Text('Might have impact on performance'.i18n), + leading: const Icon(Icons.blur_on), + trailing: Switch( + value: settings.blurPlayerBackground, + onChanged: (bool v) async { + setState(() => settings.blurPlayerBackground = v); + await settings.save(); + }, + ), + ), + ListTile( + title: Text('Visualizer'.i18n), + subtitle: Text( + 'Show visualizers on lyrics page. WARNING: Requires microphone permission!' + .i18n), + leading: const Icon(Icons.equalizer), + trailing: Switch( + value: settings.lyricsVisualizer, + onChanged: (bool v) async { + if (await Permission.microphone.request().isGranted) { + setState(() => settings.lyricsVisualizer = v); + await settings.save(); + return; + } + }, + ), + enabled: false, + ), + ListTile( + title: Text('Primary color'.i18n), + leading: const Icon(Icons.format_paint), + subtitle: Text( + 'Selected color'.i18n, + style: TextStyle(color: settings.primaryColor), + ), + onTap: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Primary color'.i18n), + content: SizedBox( + 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) { + setState(() { + settings.primaryColor = color!; + }); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ), + ), + ); + }); + }, + ), + ListTile( + title: Text('Use album art primary color'.i18n), + subtitle: Text('Warning: might be buggy'.i18n), + leading: const Icon(Icons.invert_colors), + trailing: Switch( + value: settings.useArtColor, + onChanged: (v) => setState(() => settings.updateUseArtColor(v)), + ), + ), + //Display mode + ListTile( + leading: const Icon(Icons.screen_lock_portrait), + title: Text('Change display mode'.i18n), + subtitle: Text('Enable high refresh rates'.i18n), + onTap: () async { + List modes = await FlutterDisplayMode.supported; + if (!context.mounted) return; + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: Text('Display mode'.i18n), + children: List.generate( + modes.length, + (i) => SimpleDialogOption( + child: Text(modes[i].toString()), + onPressed: () async { + settings.displayMode = i; + await settings.save(); + await FlutterDisplayMode.setPreferredMode( + modes[i]); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ))); + }); + }, + ) + ], + ), + ); + } +} + +class FontSelector extends StatefulWidget { + final Function callback; + + const FontSelector(this.callback, {super.key}); + + @override + _FontSelectorState createState() => _FontSelectorState(); +} + +class _FontSelectorState extends State { + String query = ''; + List get fonts { + return settings.fonts + .where((f) => f.toLowerCase().contains(query)) + .toList(); + } + + //Font selected + void onTap(String font) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Warning'.i18n), + content: Text( + "This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!" + .i18n), + actions: [ + TextButton( + onPressed: () async { + setState(() => settings.font = font); + await settings.save(); + if (context.mounted) Navigator.of(context).pop(); + widget.callback(); + //Global setState + updateTheme(); + }, + child: Text('Apply'.i18n), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + widget.callback(); + }, + child: const Text('Cancel'), + ) + ], + )); + } + + @override + Widget build(BuildContext context) { + return SimpleDialog( + title: Text('Select font'.i18n), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: TextField( + decoration: InputDecoration(hintText: 'Search'.i18n), + onChanged: (q) => setState(() => query = q), + ), + ), + ...List.generate( + fonts.length, + (i) => SimpleDialogOption( + child: Text(fonts[i]), + onPressed: () => onTap(fonts[i]), + )) + ], + ); + } +} + +class QualitySettings extends StatefulWidget { + const QualitySettings({super.key}); + + @override + _QualitySettingsState createState() => _QualitySettingsState(); +} + +class _QualitySettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Quality'.i18n), + body: ListView( + children: [ + ListTile( + title: Text('Mobile streaming'.i18n), + leading: + const LeadingIcon(Icons.network_cell, color: Color(0xff384697)), + ), + const QualityPicker('mobile'), + const FreezerDivider(), + ListTile( + title: Text('Wifi streaming'.i18n), + leading: + const LeadingIcon(Icons.network_wifi, color: Color(0xff0880b5)), + ), + const QualityPicker('wifi'), + const FreezerDivider(), + ListTile( + title: Text('Offline'.i18n), + leading: + const LeadingIcon(Icons.offline_pin, color: Color(0xff009a85)), + ), + const QualityPicker('offline'), + const FreezerDivider(), + ListTile( + title: Text('External downloads'.i18n), + leading: const LeadingIcon(Icons.file_download, + color: Color(0xff2ba766)), + ), + const QualityPicker('download'), + ], + ), + ); + } +} + +class QualityPicker extends StatefulWidget { + final String field; + const QualityPicker(this.field, {super.key}); + + @override + _QualityPickerState createState() => _QualityPickerState(); +} + +class _QualityPickerState extends State { + late AudioQuality _quality; + + @override + void initState() { + _getQuality(); + super.initState(); + } + + //Get current quality + void _getQuality() { + switch (widget.field) { + case 'mobile': + _quality = settings.mobileQuality; + break; + case 'wifi': + _quality = settings.wifiQuality; + break; + case 'download': + _quality = settings.downloadQuality; + break; + case 'offline': + _quality = settings.offlineQuality; + break; + } + } + + //Update quality in settings + void _updateQuality(AudioQuality q) async { + setState(() { + _quality = q; + }); + switch (widget.field) { + case 'mobile': + settings.mobileQuality = _quality; + settings.updateAudioServiceQuality(); + break; + case 'wifi': + settings.wifiQuality = _quality; + settings.updateAudioServiceQuality(); + break; + case 'download': + settings.downloadQuality = _quality; + break; + case 'offline': + settings.offlineQuality = _quality; + break; + } + await settings.save(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: const Text('MP3 128kbps'), + leading: Radio( + groupValue: _quality, + value: AudioQuality.MP3_128, + onChanged: (q) => _updateQuality(q!), + ), + ), + ListTile( + title: const Text('MP3 320kbps'), + leading: Radio( + groupValue: _quality, + value: AudioQuality.MP3_320, + onChanged: (q) => _updateQuality(q!), + ), + ), + ListTile( + title: const Text('FLAC'), + leading: Radio( + groupValue: _quality, + value: AudioQuality.FLAC, + onChanged: (q) => _updateQuality(q!), + ), + ), + if (widget.field == 'download') + ListTile( + title: Text('Ask before downloading'.i18n), + leading: Radio( + groupValue: _quality, + value: AudioQuality.ASK, + onChanged: (q) => _updateQuality(q!), + )) + ], + ); + } +} + +class ContentLanguage { + String code; + String name; + ContentLanguage(this.code, this.name); + + static List get all => [ + ContentLanguage('cs', 'Čeština'), + ContentLanguage('da', 'Dansk'), + ContentLanguage('de', 'Deutsch'), + ContentLanguage('en', 'English'), + ContentLanguage('us', 'English (us)'), + ContentLanguage('es', 'Español'), + ContentLanguage('mx', 'Español (latam)'), + ContentLanguage('fr', 'Français'), + ContentLanguage('hr', 'Hrvatski'), + ContentLanguage('id', 'Indonesia'), + ContentLanguage('it', 'Italiano'), + ContentLanguage('hu', 'Magyar'), + ContentLanguage('ms', 'Melayu'), + ContentLanguage('nl', 'Nederlands'), + ContentLanguage('no', 'Norsk'), + ContentLanguage('pl', 'Polski'), + ContentLanguage('br', 'Português (br)'), + ContentLanguage('pt', 'Português (pt)'), + ContentLanguage('ro', 'Română'), + ContentLanguage('sk', 'Slovenčina'), + ContentLanguage('sl', 'Slovenščina'), + ContentLanguage('sq', 'Shqip'), + ContentLanguage('sr', 'Srpski'), + ContentLanguage('fi', 'Suomi'), + ContentLanguage('sv', 'Svenska'), + ContentLanguage('tr', 'Türkçe'), + ContentLanguage('bg', 'Български'), + ContentLanguage('ru', 'Pусский'), + ContentLanguage('uk', 'Українська'), + ContentLanguage('he', 'עִברִית'), + ContentLanguage('ar', 'العربیة'), + ContentLanguage('cn', '中文'), + ContentLanguage('ja', '日本語'), + ContentLanguage('ko', '한국어'), + ContentLanguage('th', 'ภาษาไทย'), + ]; +} + +class DeezerSettings extends StatefulWidget { + const DeezerSettings({super.key}); + + @override + _DeezerSettingsState createState() => _DeezerSettingsState(); +} + +class _DeezerSettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Deezer'.i18n), + body: ListView( + children: [ + ListTile( + title: Text('Content language'.i18n), + subtitle: Text('Not app language, used in headers. Now'.i18n + + ': ${settings.deezerLanguage}'), + leading: const Icon(Icons.language), + onTap: () { + showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text('Select language'.i18n), + children: List.generate( + ContentLanguage.all.length, + (i) => ListTile( + title: Text(ContentLanguage.all[i].name), + subtitle: Text(ContentLanguage.all[i].code), + onTap: () async { + setState(() => settings.deezerLanguage = + ContentLanguage.all[i].code); + await settings.save(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + )), + )); + }, + ), + ListTile( + title: Text('Content country'.i18n), + subtitle: Text('Country used in headers. Now'.i18n + + ': ${settings.deezerCountry}'), + leading: const Icon(Icons.vpn_lock), + onTap: () { + showDialog( + context: context, + builder: (context) => CountryPickerDialog( + title: Text('Select country'.i18n), + titlePadding: const EdgeInsets.all(8.0), + isSearchable: true, + itemBuilder: (country) => Row( + children: [ + CountryPickerUtils.getDefaultFlagImage(country), + const SizedBox( + width: 8.0, + ), + Expanded( + child: Text( + '${country.name} (${country.isoCode})', + )) + ], + ), + onValuePicked: (Country country) { + setState(() => + settings.deezerCountry = country.isoCode ?? 'us'); + settings.save(); + }, + )); + }, + ), + ListTile( + title: Text('Log tracks'.i18n), + subtitle: Text( + 'Send track listen logs to Deezer, enable it for features like Flow to work properly' + .i18n), + trailing: Switch( + value: settings.logListen, + onChanged: (bool v) { + setState(() => settings.logListen = v); + settings.save(); + }, + ), + leading: const Icon(Icons.history_toggle_off), + ), + //TODO: Reimplement proxy +// ListTile( +// title: Text('Proxy'.i18n), +// leading: Icon(Icons.vpn_key), +// subtitle: Text(settings.proxyAddress??'Not set'.i18n), +// 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: [ +// TextButton( +// child: Text('Cancel'.i18n), +// onPressed: () => Navigator.of(context).pop(), +// ), +// TextButton( +// child: Text('Reset'.i18n), +// onPressed: () async { +// setState(() { +// settings.proxyAddress = null; +// }); +// await settings.save(); +// Navigator.of(context).pop(); +// }, +// ), +// TextButton( +// child: Text('Save'.i18n), +// onPressed: () async { +// setState(() { +// settings.proxyAddress = _new; +// }); +// await settings.save(); +// Navigator.of(context).pop(); +// }, +// ) +// ], +// ); +// } +// ); +// }, +// ) + ], + ), + ); + } +} + +class FilenameTemplateDialog extends StatefulWidget { + final String initial; + final Function onSave; + const FilenameTemplateDialog(this.initial, this.onSave, {super.key}); + + @override + _FilenameTemplateDialogState createState() => _FilenameTemplateDialogState(); +} + +class _FilenameTemplateDialogState extends State { + late TextEditingController _controller; + late String _new; + + @override + void initState() { + _controller = TextEditingController(text: widget.initial); + _new = _controller.value.text; + super.initState(); + } + + @override + Widget build(BuildContext context) { + //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%, %date%\n\n' + + "If you want to use custom directory naming - use '/' as directory separator." + .i18n, + style: const TextStyle( + fontSize: 12.0, + ), + ) + ], + ), + actions: [ + TextButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text('Reset'.i18n), + onPressed: () { + _controller.value = + _controller.value.copyWith(text: '%artist% - %title%'); + _new = '%artist% - %title%'; + }, + ), + TextButton( + child: Text('Clear'.i18n), + onPressed: () => _controller.clear(), + ), + TextButton( + child: Text('Save'.i18n), + onPressed: () async { + widget.onSave(_new); + Navigator.of(context).pop(); + }, + ) + ], + ); + } +} + +class DownloadsSettings extends StatefulWidget { + const DownloadsSettings({super.key}); + + @override + _DownloadsSettingsState createState() => _DownloadsSettingsState(); +} + +class _DownloadsSettingsState extends State { + double _downloadThreads = settings.downloadThreads.toDouble(); + final TextEditingController _artistSeparatorController = + TextEditingController(text: settings.artistSeparator); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Download Settings'.i18n), + body: ListView( + children: [ + ListTile( + title: Text('Download path'.i18n), + leading: const Icon(Icons.folder), + subtitle: Text(settings.downloadPath ?? 'Not set'.i18n), + onTap: () async { + //Check permissions + //if (!(await Permission.storage.request().isGranted)) return; + if (await FileUtils.checkStoragePermission()) { + //Navigate + if (context.mounted) { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => DirectoryPicker( + settings.downloadPath ?? '', + onSelect: (String p) async { + setState(() => settings.downloadPath = p); + await settings.save(); + }, + ))); + } + } else { + Fluttertoast.showToast( + msg: 'Storage permission denied!'.i18n, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM); + return; + } + }, + ), + ListTile( + title: Text('Downloads naming'.i18n), + subtitle: Text('Currently'.i18n + ': ${settings.downloadFilename}'), + leading: const Icon(Icons.text_format), + onTap: () { + showDialog( + context: context, + builder: (context) { + return FilenameTemplateDialog(settings.downloadFilename, + (f) async { + setState(() => settings.downloadFilename = f); + await settings.save(); + }); + }); + }, + ), + ListTile( + title: Text('Singleton naming'.i18n), + subtitle: + Text('Currently'.i18n + ': ${settings.singletonFilename}'), + leading: const Icon(Icons.text_format), + onTap: () { + showDialog( + context: context, + builder: (context) { + return FilenameTemplateDialog(settings.singletonFilename, + (f) async { + setState(() => settings.singletonFilename = f); + await settings.save(); + }); + }); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Download threads'.i18n + + ': ${_downloadThreads.round().toString()}', + style: const TextStyle(fontSize: 16.0), + ), + ), + Slider( + min: 1, + max: 16, + divisions: 15, + 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(); + + //Prevent null + if (val > 8 && + cache.threadsWarning != true && + context.mounted) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Warning'.i18n), + content: Text( + 'Using too many concurrent downloads on older/weaker devices might cause crashes!' + .i18n), + actions: [ + TextButton( + child: Text('Dismiss'.i18n), + onPressed: () => Navigator.of(context).pop(), + ) + ], + ); + }); + + cache.threadsWarning = true; + await cache.save(); + } + }), + const FreezerDivider(), + ListTile( + title: Text('Tags'.i18n), + leading: const Icon(Icons.label), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const TagSelectionScreen())), + ), + ListTile( + title: Text('Create folders for artist'.i18n), + trailing: Switch( + value: settings.artistFolder, + onChanged: (v) { + setState(() => settings.artistFolder = v); + settings.save(); + }, + ), + leading: const Icon(Icons.folder), + ), + ListTile( + title: Text('Create folders for albums'.i18n), + trailing: Switch( + value: settings.albumFolder, + onChanged: (v) { + setState(() => settings.albumFolder = v); + settings.save(); + }, + ), + leading: const Icon(Icons.folder)), + ListTile( + title: Text('Create folder for playlist'.i18n), + trailing: Switch( + value: settings.playlistFolder, + onChanged: (v) { + setState(() => settings.playlistFolder = v); + settings.save(); + }, + ), + leading: const Icon(Icons.folder)), + const FreezerDivider(), + ListTile( + title: Text('Separate albums by discs'.i18n), + trailing: Switch( + value: settings.albumDiscFolder, + onChanged: (v) { + setState(() => settings.albumDiscFolder = v); + settings.save(); + }, + ), + leading: const Icon(Icons.album)), + ListTile( + title: Text('Overwrite already downloaded files'.i18n), + trailing: Switch( + value: settings.overwriteDownload, + onChanged: (v) { + setState(() => settings.overwriteDownload = v); + settings.save(); + }, + ), + leading: const Icon(Icons.delete)), + ListTile( + title: Text('Download .LRC lyrics'.i18n), + trailing: Switch( + value: settings.downloadLyrics, + onChanged: (v) { + setState(() => settings.downloadLyrics = v); + settings.save(); + }, + ), + leading: const Icon(Icons.subtitles)), + const FreezerDivider(), + ListTile( + title: Text('Save cover file for every track'.i18n), + trailing: Switch( + value: settings.trackCover, + onChanged: (v) { + setState(() => settings.trackCover = v); + settings.save(); + }, + ), + leading: const Icon(Icons.image)), + ListTile( + title: Text('Save album cover'.i18n), + trailing: Switch( + value: settings.albumCover, + onChanged: (v) { + setState(() => settings.albumCover = v); + settings.save(); + }, + ), + leading: const Icon(Icons.image)), + ListTile( + title: Text('Album cover resolution'.i18n), + subtitle: Text( + "WARNING: Resolutions above 1200 aren't officially supported" + .i18n), + leading: const Icon(Icons.image), + trailing: SizedBox( + width: 75.0, + child: DropdownButton( + value: settings.albumArtResolution, + items: [400, 800, 1000, 1200, 1400, 1600, 1800] + .map>( + (int i) => DropdownMenuItem( + value: i, + child: Text(i.toString()), + )) + .toList(), + onChanged: (int? n) async { + setState(() { + settings.albumArtResolution = n ?? 400; + }); + await settings.save(); + }, + ))), + ListTile( + title: Text('Create .nomedia files'.i18n), + subtitle: + Text('To prevent gallery being filled with album art'.i18n), + trailing: Switch( + value: settings.nomediaFiles, + onChanged: (v) { + setState(() => settings.nomediaFiles = v); + settings.save(); + }, + ), + leading: const Icon(Icons.insert_drive_file)), + ListTile( + title: Text('Artist separator'.i18n), + leading: const Icon(WebSymbols.tag), + trailing: SizedBox( + width: 75.0, + child: TextField( + controller: _artistSeparatorController, + onChanged: (s) async { + settings.artistSeparator = s; + await settings.save(); + }, + ), + ), + ), + const FreezerDivider(), + ListTile( + title: Text('Download Log'.i18n), + leading: const Icon(Icons.sticky_note_2), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const DownloadLogViewer())), + ) + ], + ), + ); + } +} + +class TagOption { + String title; + String value; + TagOption(this.title, this.value); +} + +class TagSelectionScreen extends StatefulWidget { + const TagSelectionScreen({super.key}); + + @override + _TagSelectionScreenState createState() => _TagSelectionScreenState(); +} + +class _TagSelectionScreenState extends State { + List tags = [ + TagOption('Title'.i18n, 'title'), + TagOption('Album'.i18n, 'album'), + TagOption('Artist'.i18n, 'artist'), + TagOption('Track number'.i18n, 'track'), + TagOption('Disc number'.i18n, 'disc'), + TagOption('Album artist'.i18n, 'albumArtist'), + TagOption('Date/Year'.i18n, 'date'), + TagOption('Label'.i18n, 'label'), + TagOption('ISRC'.i18n, 'isrc'), + TagOption('UPC'.i18n, 'upc'), + TagOption('Track total'.i18n, 'trackTotal'), + TagOption('BPM'.i18n, 'bpm'), + TagOption('Unsynchronized lyrics'.i18n, 'lyrics'), + TagOption('Genre'.i18n, 'genre'), + TagOption('Contributors'.i18n, 'contributors'), + TagOption('Album art'.i18n, 'art') + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Tags'.i18n), + body: ListView( + children: List.generate( + tags.length, + (i) => ListTile( + title: Text(tags[i].title), + leading: Switch( + value: settings.tags.contains(tags[i].value), + onChanged: (v) async { + //Update + if (v) { + settings.tags.add(tags[i].value); + } else { + settings.tags.remove(tags[i].value); + } + setState(() {}); + await settings.save(); + }, + ), + )), + ), + ); + } +} + +class GeneralSettings extends StatefulWidget { + const GeneralSettings({super.key}); + + @override + _GeneralSettingsState createState() => _GeneralSettingsState(); +} + +class _GeneralSettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('General'.i18n), + body: ListView( + children: [ + ListTile( + title: Text('Offline mode'.i18n), + subtitle: Text('Will be overwritten on start.'.i18n), + trailing: Switch( + value: settings.offlineMode, + onChanged: (bool v) { + if (v) { + setState(() => settings.offlineMode = true); + return; + } + showDialog( + context: context, + builder: (context) { + deezerAPI.authorize().then((v) async { + 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: const Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + )); + }); + }, + ), + leading: const Icon(Icons.lock), + ), + ListTile( + title: Text('Copy ARL'.i18n), + subtitle: + Text('Copy userToken/ARL Cookie for use in other apps.'.i18n), + leading: const Icon(Icons.lock), + onTap: () async { + await FlutterClipboard.copy(settings.arl ?? ''); + await Fluttertoast.showToast( + msg: 'Copied'.i18n, + ); + }, + ), + ListTile( + title: Text('Enable equalizer'.i18n), + subtitle: Text( + 'Might enable some equalizer apps to work. Requires restart of Freezer' + .i18n), + leading: const Icon(Icons.equalizer), + trailing: Switch( + value: settings.enableEqualizer, + onChanged: (v) async { + setState(() => settings.enableEqualizer = v); + settings.save(); + }, + ), + ), + ListTile( + title: Text('LastFM'.i18n), + subtitle: Text((settings.lastFMUsername != null) + ? 'Log out'.i18n + : 'Login to enable scrobbling.'.i18n), + leading: const Icon(FontAwesome5.lastfm), + onTap: () async { + if (settings.lastFMUsername != null) { + //Log out + settings.lastFMUsername = null; + settings.lastFMPassword = null; + await settings.save(); + await GetIt.I().disableLastFM(); + //await GetIt.I().customAction('disableLastFM', Map()); + setState(() {}); + Fluttertoast.showToast(msg: 'Logged out!'.i18n); + return; + } else { + showDialog( + context: context, + builder: (context) => const LastFMLogin(), + ).then((_) { + setState(() {}); + }); + } + }, + //enabled: false, + ), + ListTile( + title: Text('Ignore interruptions'.i18n), + subtitle: Text('Requires app restart to apply!'.i18n), + leading: const Icon(Icons.not_interested), + trailing: Switch( + value: settings.ignoreInterruptions, + onChanged: (bool v) async { + setState(() => settings.ignoreInterruptions = v); + await settings.save(); + }, + ), + ), + ListTile( + title: Text('Application Log'.i18n), + leading: const Icon(Icons.sticky_note_2), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const ApplicationLogViewer())), + ), + const FreezerDivider(), + ListTile( + title: Text( + 'Log out'.i18n, + style: const TextStyle(color: Colors.red), + ), + leading: const Icon(Icons.exit_to_app), + onTap: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Log out'.i18n), + // There was no Incompatability, cookies just needed to be cleared... + // content: Text('Due to plugin incompatibility, login using browser is unavailable without restart.'.i18n), + // content: Text('Restart of app is required to properly log out!'.i18n), + content: Text('Are you sure you want to log out?'.i18n), + actions: [ + TextButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + //child: Text('(ARL ONLY) Continue'.i18n), + child: Text('Continue'.i18n), + onPressed: () async { + await logOut(); + if (context.mounted) Navigator.of(context).pop(); + }, + ), + /* TextButton( + child: Text('Log out & Exit'.i18n), + onPressed: () async { + try { + GetIt.I().stop(); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + await logOut(); + await DownloadManager.platform.invokeMethod('kill'); + //SystemNavigator.pop(); + Restart.restartApp(); + }, + )*/ + ], + ); + }); + }), + ], + ), + ); + } +} + +class LastFMLogin extends StatefulWidget { + const LastFMLogin({super.key}); + + @override + _LastFMLoginState createState() => _LastFMLoginState(); +} + +class _LastFMLoginState extends State { + String _username = ''; + String _password = ''; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Login to LastFM'.i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: InputDecoration(hintText: 'Username'.i18n), + onChanged: (v) => _username = v, + ), + Container(height: 8.0), + TextField( + obscureText: true, + decoration: InputDecoration(hintText: 'Password'.i18n), + onChanged: (v) => _password = v, + ) + ], + ), + actions: [ + TextButton( + child: Text('Cancel'.i18n), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text('Login'.i18n), + onPressed: () async { + LastFM last; + try { + last = await LastFM.authenticate( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: _username, + password: _password); + } catch (e) { + Logger.root.severe('Error authorizing LastFM: $e'); + Fluttertoast.showToast(msg: 'Authorization error!'.i18n); + return; + } + //Save + settings.lastFMUsername = last.username; + settings.lastFMPassword = last.passwordHash; + await settings.save(); + await GetIt.I().authorizeLastFM(); + if (context.mounted) Navigator.of(context).pop(); + }, + ), + ], + ); + } +} + +class StorageInfo { + final String rootDir; + final String appFilesDir; + final int availableBytes; + + StorageInfo( + {required this.rootDir, + required this.appFilesDir, + required this.availableBytes}); +} + +Future> getStorageInfo() async { + final externalDirectories = + await ExternalPath.getExternalStorageDirectories(); + + List storageInfoList = []; + + if (externalDirectories.isNotEmpty) { + for (var dir in externalDirectories) { + var availableMegaBytes = + (await DiskSpacePlus.getFreeDiskSpaceForPath(dir)) ?? 0.0; + + storageInfoList.add( + StorageInfo( + rootDir: dir, + appFilesDir: dir, + availableBytes: availableMegaBytes > 0 + ? (availableMegaBytes * 1000000).floor() + : 0, + ), + ); + } + } + + return storageInfoList; +} + +class DirectoryPicker extends StatefulWidget { + final String initialPath; + final Function onSelect; + const DirectoryPicker(this.initialPath, {required this.onSelect, super.key}); + + @override + _DirectoryPickerState createState() => _DirectoryPickerState(); +} + +class _DirectoryPickerState extends State { + late String _path; + String? _previous; + String? _root; + + // Alternative Native file picker, not skinned + // DirectoryLocation? _pickedDirectory; + // Future _isPickDirectorySupported = FlutterFileDialog.isPickDirectorySupported(); + + @override + void initState() { + _path = widget.initialPath; + super.initState(); + } + + Future _resetPath() async { + final appFilesDir = await getApplicationDocumentsDirectory(); + setState(() => _path = appFilesDir.path); + } + + /*Future _pickDirectory() async { + _pickedDirectory = (await FlutterFileDialog.pickDirectory()); + setState(() {}); + }*/ + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar( + 'Pick-a-Path'.i18n, + actions: [ + IconButton( + icon: Icon( + Icons.sd_card, + semanticLabel: 'Select storage'.i18n, + ), + onPressed: () { + //_pickDirectory(); + //Chose storage + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Select storage'.i18n), + content: FutureBuilder( + //future: PathProviderEx.getStorageInfo(), + future: getStorageInfo(), + builder: (context, snapshot) { + if (snapshot.hasError) return const ErrorScreen(); + if (!snapshot.hasData) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator() + ], + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...List.generate(snapshot.data?.length ?? 0, + (i) { + StorageInfo si = snapshot.data![i]; + return ListTile( + title: Text(si.rootDir), + leading: const Icon(Icons.sd_card), + trailing: Text(filesize(si.availableBytes)), + onTap: () { + setState(() { + _path = si.appFilesDir; + _root = si.rootDir; + if (i != 0) _root = si.appFilesDir; + }); + Navigator.of(context).pop(); + }, + ); + }) + ], + ); + }, + ), + ); + }); + }) + ], + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.done), + onPressed: () { + //When folder confirmed + widget.onSelect(_path); + Navigator.of(context).pop(); + }, + ), + body: FutureBuilder( + future: Directory(_path).list().toList(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + //On error go to last good path + if (snapshot.hasError) { + Future.delayed(const Duration(milliseconds: 50), () { + if (_previous == null) { + _resetPath(); + return; + } + setState(() => _path = _previous!); + }); + } + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + List data = snapshot.data; + return ListView( + children: [ + ListTile( + title: Text(_path), + ), + ListTile( + title: Text('Go up'.i18n), + leading: const Icon(Icons.arrow_upward), + onTap: () { + setState(() { + if (_root == _path) { + Fluttertoast.showToast( + msg: 'Permission denied'.i18n, + gravity: ToastGravity.BOTTOM); + return; + } + _previous = _path; + _path = Directory(_path).parent.path; + }); + }, + ), + ...List.generate(data.length, (i) { + FileSystemEntity f = data[i]; + if (f is Directory) { + return ListTile( + title: Text(f.path.split('/').last), + leading: const Icon(Icons.folder), + onTap: () { + setState(() { + _previous = _path; + _path = f.path; + }); + }, + ); + } + return const SizedBox( + height: 0, + width: 0, + ); + }) + ], + ); + }, + ), + ); + } +} + +class CreditsScreen extends StatefulWidget { + const CreditsScreen({super.key}); + + @override + _CreditsScreenState createState() => _CreditsScreenState(); +} + +class _CreditsScreenState extends State { + String _version = ''; + + static final List> translators = [ + ['Xandar Null', 'Arabic'], + ['Markus', 'German'], + ['Andrea', 'Italian'], + ['Diego Hiro', 'Portuguese'], + ['Orfej', 'Russian'], + ['Chino Pacia', 'Filipino'], + ['ArcherDelta & PetFix', 'Spanish'], + ['Shazzaam', 'Croatian'], + ['VIRGIN_KLM', 'Greek'], + ['koreezzz', 'Korean'], + ['Fwwwwwwwwwweze', 'French'], + ['kobyrevah', 'Hebrew'], + ['HoScHaKaL', 'Turkish'], + ['MicroMihai', 'Romanian'], + ['LenteraMalam', 'Indonesian'], + ['RTWO2', 'Persian'] + ]; + + @override + void initState() { + PackageInfo.fromPlatform().then((info) { + setState(() { + _version = 'v${info.version}'; + }); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('About'.i18n), + body: ListView( + children: [ + const FreezerTitle(), + Text( + _version, + textAlign: TextAlign.center, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + const FreezerDivider(), + const ListTile( + title: Text('DJDoubleD', + style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('Developer, tester, new icon & logo, ...'), + ), + const FreezerDivider(), + /*ListTile( + title: Text('Telegram Channel'.i18n), + subtitle: Text('To get latest releases'.i18n), + leading: const Icon(FontAwesome5.telegram, color: Color(0xFF27A2DF), size: 36.0), + onTap: () { + launchUrlString('https://t.me/joinchat/Se4zLEBvjS1NCiY9'); + }, + ), + ListTile( + title: Text('Telegram Group'.i18n), + subtitle: Text('Official chat'.i18n), + leading: const Icon(FontAwesome5.telegram, color: Colors.cyan, size: 36.0), + onTap: () { + launchUrlString('https://t.me/freezerandroid'); + }, + ), + ListTile( + title: Text('Discord'.i18n), + subtitle: Text('Official Discord server'.i18n), + leading: const Icon(FontAwesome5.discord, color: Color(0xff7289da), size: 36.0), + onTap: () { + launchUrlString('https://discord.gg/qwJpa3r4dQ'); + }, + ),*/ + ListTile( + title: Text('Repository'.i18n), + subtitle: Text('Source code, report issues there.'.i18n), + leading: const Icon(Icons.code, color: Colors.green, size: 36.0), + onTap: () { + launchUrlString('https://github.com/DJDoubleD/ReFreezer'); + }, + ), + ListTile( + title: Text('Donate'.i18n), + subtitle: Text( + 'You should rather support your favorite artists, instead of this app!' + .i18n), + leading: + const Icon(FontAwesome5.paypal, color: Colors.blue, size: 36.0), + onTap: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Donate'.i18n), + content: Text( + 'No really, go support your favorite artists instead ;)' + .i18n), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + if (context.mounted) Navigator.of(context).pop(); + }, + ) + ], + ); + }); + // launchUrlString('https://paypal.me/exttex'); + }, + ), + const Padding(padding: EdgeInsets.all(8.0)), + const FreezerDivider(), + const Padding(padding: EdgeInsets.all(8.0)), + const FreezerDivider(), + Padding( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + //const Icon(Icons.favorite_border), + Image.asset('assets/icon_legacy.png', width: 24, height: 24), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), + child: Text( + 'The original freezer development team'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16.0, fontWeight: FontWeight.w600), + ), + ), + //const Icon(Icons.favorite_border), + Image.asset('assets/icon_legacy.png', width: 24, height: 24), + ]), + ), + const FreezerDivider(), + const ListTile( + title: Text('exttex'), + subtitle: Text('Developer'), + ), + const ListTile( + title: Text('Bas Curtiz'), + subtitle: Text('Icon, logo, banner, design suggestions, tester'), + ), + const ListTile( + title: Text('Tobs'), + subtitle: Text('Alpha testers'), + ), + const ListTile( + title: Text('Deemix'), + subtitle: Text('Better app <3'), + ), + const ListTile( + title: Text('Xandar Null'), + subtitle: Text('Tester, translations help'), + ), + ListTile( + title: const Text('Francesco'), + subtitle: const Text('Tester'), + onTap: () { + setState(() { + settings.primaryColor = const Color(0xff333333); + }); + updateTheme(); + settings.save(); + }, + ), + const ListTile( + title: Text('Annexhack'), + subtitle: Text('Android Auto help'), + ), + const FreezerDivider(), + ...List.generate( + translators.length, + (i) => ListTile( + title: Text(translators[i][0]), + subtitle: Text(translators[i][1]), + )), + Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), + child: Text( + 'Huge thanks to all the contributors! <3'.i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ), + ) + ], + ), + ); + } +} diff --git a/lib/ui/tiles.dart b/lib/ui/tiles.dart new file mode 100644 index 0000000..7577511 --- /dev/null +++ b/lib/ui/tiles.dart @@ -0,0 +1,668 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:fluttericon/octicons_icons.dart'; +import 'package:get_it/get_it.dart'; + +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../api/download.dart'; +import '../service/audio_service.dart'; +import '../translations.i18n.dart'; +import 'cached_image.dart'; + +class TrackTile extends StatefulWidget { + final Track track; + final VoidCallback? onTap; + final VoidCallback? onHold; + final Widget? trailing; + + const TrackTile(this.track, + {this.onTap, this.onHold, this.trailing, super.key}); + + @override + _TrackTileState createState() => _TrackTileState(); +} + +class _TrackTileState extends State { + StreamSubscription? _mediaItemSub; + bool _isOffline = false; + bool nowPlaying = false; + + /*bool get nowPlaying { + if (GetIt.I().mediaItem.value == null) return false; + return GetIt.I().mediaItem.value!.id == widget.track.id; + }*/ + + @override + void initState() { + //Listen to media item changes, update text color if currently playing + _mediaItemSub = GetIt.I().mediaItem.listen((item) { + if (mounted) { + setState(() { + nowPlaying = widget.track.id == item?.id; + }); + } + }); + //Check if offline + downloadManager.checkOffline(track: widget.track).then((b) { + if (mounted) { + setState(() => _isOffline = b); + } + }); + + super.initState(); + } + + @override + void dispose() { + _mediaItemSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + widget.track.title ?? '', + maxLines: 1, + overflow: TextOverflow.clip, + style: TextStyle( + color: nowPlaying ? Theme.of(context).primaryColor : null), + ), + subtitle: Text( + widget.track.artistString ?? '', + maxLines: 1, + ), + leading: CachedImage( + url: widget.track.albumArt?.thumb ?? '', + width: 48, + ), + onTap: widget.onTap, + onLongPress: widget.onHold, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isOffline) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2.0), + child: Icon( + Octicons.primitive_dot, + color: Colors.green, + size: 12.0, + ), + ), + if (widget.track.explicit ?? false) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2.0), + child: Text( + 'E', + style: TextStyle(color: Colors.red), + ), + ), + SizedBox( + width: 42.0, + child: Text( + widget.track.durationString ?? '', + textAlign: TextAlign.center, + ), + ), + widget.trailing ?? const SizedBox(width: 0, height: 0) + ], + ), + ); + } +} + +class AlbumTile extends StatelessWidget { + final Album album; + final VoidCallback? onTap; + final VoidCallback? onHold; + final Widget? trailing; + + const AlbumTile(this.album, + {super.key, this.onTap, this.onHold, this.trailing}); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + album.title ?? '', + maxLines: 1, + ), + subtitle: Text( + album.artistString ?? '', + maxLines: 1, + ), + leading: CachedImage( + url: album.art?.thumb ?? '', + width: 48, + ), + onTap: onTap, + onLongPress: onHold, + trailing: trailing, + ); + } +} + +class ArtistTile extends StatelessWidget { + final Artist artist; + final VoidCallback? onTap; + final VoidCallback? onHold; + + const ArtistTile(this.artist, {super.key, this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 150, + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 4, + ), + CachedImage( + url: artist.picture?.thumb ?? '', + circular: true, + width: 100, + ), + Container( + height: 8, + ), + Text( + artist.name ?? '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14.0), + ), + Container( + height: 4, + ), + ], + ), + )); + } +} + +class PlaylistTile extends StatelessWidget { + final Playlist playlist; + final VoidCallback? onTap; + final VoidCallback? onHold; + final Widget? trailing; + + const PlaylistTile(this.playlist, + {super.key, this.onHold, this.onTap, this.trailing}); + + String get subtitle { + if (playlist.user?.name == '' || playlist.user?.id == deezerAPI.userId) { + if (playlist.trackCount == null) return ''; + return '${playlist.trackCount} ' + 'Tracks'.i18n; + } + return playlist.user?.name ?? ''; + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + playlist.title ?? '', + maxLines: 1, + ), + subtitle: Text( + subtitle, + maxLines: 1, + ), + leading: CachedImage( + url: playlist.image?.thumb ?? '', + width: 48, + ), + onTap: onTap, + onLongPress: onHold, + trailing: trailing, + ); + } +} + +class ArtistHorizontalTile extends StatelessWidget { + final Artist artist; + final VoidCallback? onTap; + final VoidCallback? onHold; + final Widget? trailing; + + const ArtistHorizontalTile(this.artist, + {super.key, this.onHold, this.onTap, this.trailing}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: ListTile( + title: Text( + artist.name ?? '', + maxLines: 1, + ), + leading: CachedImage( + url: artist.picture?.thumb ?? '', + circular: true, + ), + onTap: onTap, + onLongPress: onHold, + trailing: trailing, + ), + ); + } +} + +class PlaylistCardTile extends StatelessWidget { + final Playlist playlist; + final VoidCallback? onTap; + final VoidCallback? onHold; + const PlaylistCardTile(this.playlist, {super.key, this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 180.0, + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: CachedImage( + url: playlist.image?.thumb ?? '', + width: 128, + height: 128, + rounded: true, + ), + ), + Container(height: 2.0), + SizedBox( + width: 144, + child: Text( + playlist.title ?? '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14.0), + ), + ), + Container( + height: 4.0, + ) + ], + ), + )); + } +} + +class SmartTrackListTile extends StatelessWidget { + final SmartTrackList smartTrackList; + final VoidCallback? onTap; + final VoidCallback? onHold; + const SmartTrackListTile(this.smartTrackList, + {super.key, this.onHold, this.onTap}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 212.0, + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + children: [ + CachedImage( + width: 128, + height: 128, + url: smartTrackList.cover?.thumb ?? '', + rounded: true, + ), + SizedBox( + width: 128.0, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 6.0), + child: Text( + smartTrackList.title ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18.0, + shadows: [ + Shadow( + offset: Offset(1, 1), + blurRadius: 2, + color: Colors.black) + ], + color: Colors.white), + ), + ), + ) + ], + )), + SizedBox( + width: 144.0, + child: Text( + smartTrackList.subtitle ?? '', + maxLines: 3, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14.0), + ), + ), + Container( + height: 8.0, + ) + ], + ), + ), + ); + } +} + +class FlowTrackListTile extends StatelessWidget { + final DeezerFlow deezerFlow; + final VoidCallback? onTap; + final VoidCallback? onHold; + const FlowTrackListTile(this.deezerFlow, + {super.key, this.onHold, this.onTap}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 150, + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 4, + ), + CachedImage( + url: deezerFlow.cover?.thumb ?? '', + circular: true, + width: 100, + ), + Container( + height: 8, + ), + Text( + deezerFlow.title ?? '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14.0), + ), + Container( + height: 4, + ), + ], + ), + )); + } +} + +class AlbumCard extends StatelessWidget { + final Album album; + final VoidCallback? onTap; + final VoidCallback? onHold; + + const AlbumCard(this.album, {super.key, this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: CachedImage( + width: 128.0, + height: 128.0, + url: album.art?.thumb ?? '', + rounded: true), + ), + SizedBox( + width: 144.0, + child: Text( + album.title ?? '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14.0), + ), + ), + Container(height: 4.0), + SizedBox( + width: 144.0, + child: Text( + album.artistString ?? '', + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.0, + color: (Theme.of(context).brightness == Brightness.light) + ? Colors.grey[800] + : Colors.white70), + ), + ), + Container( + height: 8.0, + ) + ], + ), + ); + } +} + +class ChannelTile extends StatelessWidget { + final DeezerChannel channel; + final VoidCallback? onTap; + const ChannelTile(this.channel, {super.key, this.onTap}); + + Color _textColor() { + if (channel.backgroundImage == null) { + double luminance = channel.backgroundColor!.computeLuminance(); + return (luminance > 0.5) ? Colors.black : Colors.white; + } else { + // Deezer website seems to always use white for title over logo image + return Colors.white; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Card( + color: channel.backgroundImage == null ? channel.backgroundColor : null, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: 148, + height: 75, + child: Center( + child: Stack( + children: [ + if (channel.backgroundImage != null) + CachedImage( + url: channel.backgroundImage + ?.customUrl('134', '264', quality: '100') ?? + '', + width: 150, + height: 75, + ), + if (channel.logoImage != null) + CachedImage( + url: channel.logoImage?.thumbUrl ?? '', + width: 150, + height: 75, + ), + if (channel.title != null && channel.logo == null) + Center( + child: Text( + channel.title!, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.bold, + color: _textColor()), + )) + ], + )), + ), + ), + ), + ); + } +} + +class ShowCard extends StatelessWidget { + final Show show; + final VoidCallback? onTap; + final VoidCallback? onHold; + + const ShowCard(this.show, {super.key, this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: CachedImage( + url: show.art?.thumb ?? '', + width: 128.0, + height: 128.0, + rounded: true, + ), + ), + SizedBox( + width: 144.0, + child: Text( + show.name ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14.0), + ), + ), + ], + ), + ); + } +} + +class ShowTile extends StatelessWidget { + final Show show; + final VoidCallback? onTap; + final VoidCallback? onHold; + + const ShowTile(this.show, {super.key, this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + show.name ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + show.description ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: onTap, + onLongPress: onHold, + leading: CachedImage( + url: show.art?.thumb ?? '', + width: 48, + ), + ); + } +} + +class ShowEpisodeTile extends StatelessWidget { + final ShowEpisode episode; + final VoidCallback? onTap; + final VoidCallback? onHold; + final Widget? trailing; + + const ShowEpisodeTile(this.episode, + {super.key, this.onTap, this.onHold, this.trailing}); + + @override + Widget build(BuildContext context) { + return InkWell( + onLongPress: onHold, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(episode.title ?? '', maxLines: 2), + trailing: trailing, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + episode.description ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context) + .textTheme + .titleMedium + ?.color + ?.withOpacity(0.9)), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8.0, 0, 0), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Text( + '${episode.publishedDate} | ${episode.durationString}', + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.bold, + color: Theme.of(context) + .textTheme + .titleMedium + ?.color + ?.withOpacity(0.6)), + ), + ], + ), + ), + const Divider(), + ], + ), + ); + } +} diff --git a/lib/ui/updater.dart b/lib/ui/updater.dart new file mode 100644 index 0000000..97f9f28 --- /dev/null +++ b/lib/ui/updater.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:http/http.dart' as http; +import 'package:open_filex/open_filex.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../api/cache.dart'; +import '../api/download.dart'; +import '../translations.i18n.dart'; +import '../ui/elements.dart'; +import '../ui/error.dart'; +import '../utils/version.dart'; + +class UpdaterScreen extends StatefulWidget { + const UpdaterScreen({super.key}); + + @override + _UpdaterScreenState createState() => _UpdaterScreenState(); +} + +class _UpdaterScreenState extends State { + bool _loading = true; + bool _error = false; + FreezerVersions? _versions; + String? _current; + String? _arch; + double _progress = 0.0; + bool _buttonEnabled = true; + + Future _load() async { + //Load current version + PackageInfo info = await PackageInfo.fromPlatform(); + setState(() => _current = info.version); + + //Get architecture + _arch = await DownloadManager.platform.invokeMethod('arch'); + if (_arch == 'armv8l') _arch = 'arm32'; + + //Load from website + try { + FreezerVersions versions = await FreezerVersions.fetch(); + setState(() { + _versions = versions; + _loading = false; + }); + } catch (e, st) { + if (kDebugMode) { + print(e.toString() + st.toString()); + } + _error = true; + _loading = false; + } + } + + FreezerDownload? get _versionDownload { + return _versions?.versions[0].downloads.firstWhere((d) => d.version.toLowerCase().contains(_arch!.toLowerCase())); + } + + Future _download() async { + String? url = _versionDownload?.directUrl; + //Start request + http.Client client = http.Client(); + http.StreamedResponse res = await client.send(http.Request('GET', Uri.parse(url ?? ''))); + int? size = res.contentLength; + //Open file + String path = p.join((await getExternalStorageDirectory())!.path, 'update.apk'); + File file = File(path); + IOSink fileSink = file.openWrite(); + //Update progress + Future.doWhile(() async { + int received = await file.length(); + setState(() => _progress = received / size!.toInt()); + return received != size; + }); + //Pipe + await res.stream.pipe(fileSink); + fileSink.close(); + + OpenFilex.open(path); + setState(() => _buttonEnabled = true); + } + + @override + void initState() { + _load(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FreezerAppBar('Updates'.i18n), + body: ListView( + children: [ + if (_error) const ErrorScreen(), + if (_loading) + const Padding( + padding: EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ), + if (!_error && + !_loading && + Version.parse((_versions?.latest.toString() ?? '0.0.0')) <= Version.parse(_current!)) + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('You are running latest version!'.i18n, + textAlign: TextAlign.center, style: const TextStyle(fontSize: 26.0)), + )), + if (!_error && + !_loading && + Version.parse((_versions?.latest.toString() ?? '0.0.0')) > Version.parse(_current!)) + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'New update available!'.i18n + ' ' + _versions!.latest.toString(), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + ), + ), + Text( + 'Current version: ' + _current!, + style: const TextStyle(fontSize: 14.0, fontStyle: FontStyle.italic), + ), + Container(height: 8.0), + const FreezerDivider(), + Container(height: 8.0), + const Text( + 'Changelog', + style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + _versions!.versions[0].changelog, + style: const TextStyle(fontSize: 16.0), + ), + ), + const FreezerDivider(), + Container(height: 8.0), + //Available download + if (_versionDownload != null) + Column(children: [ + ElevatedButton( + onPressed: _buttonEnabled + ? () { + setState(() => _buttonEnabled = false); + _download(); + } + : null, + child: Text('Download'.i18n + ' (${_versionDownload?.version})')), + Padding( + padding: const EdgeInsets.all(8.0), + child: LinearProgressIndicator(value: _progress), + ) + ]), + //Unsupported arch + if (_versionDownload == null) + Text( + 'Unsupported platform!'.i18n + ' $_arch', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.0), + ) + ], + ) + ], + )); + } +} + +class FreezerVersions { + String latest; + List versions; + + FreezerVersions({required this.latest, required this.versions}); + + factory FreezerVersions.fromJson(Map data) => FreezerVersions( + latest: data['android']['latest'], + versions: data['android']['versions'].map((v) => FreezerVersion.fromJson(v)).toList()); + + //Fetch from website API + static Future fetch() async { + http.Response response = await http.get('https://freezer.life/api/versions' as Uri); +// http.Response response = await http.get('https://cum.freezerapp.workers.dev/api/versions'); + return FreezerVersions.fromJson(jsonDecode(response.body)); + } + + static Future checkUpdate() async { + //Check only each 24h + int updateDelay = 86400000; + if ((DateTime.now().millisecondsSinceEpoch - (cache.lastUpdateCheck ?? 0)) < updateDelay) return; + cache.lastUpdateCheck = DateTime.now().millisecondsSinceEpoch; + await cache.save(); + + FreezerVersions versions = await FreezerVersions.fetch(); + + //Load current version + PackageInfo info = await PackageInfo.fromPlatform(); + if (Version.parse(versions.latest) <= Version.parse(info.version)) return; + + //Get architecture + String arch = await DownloadManager.platform.invokeMethod('arch'); + if (arch == 'armv8l') arch = 'arm32'; + //Check compatible architecture + var compatibleVersion = + versions.versions[0].downloads.firstWhereOrNull((d) => d.version.toLowerCase().contains(arch.toLowerCase())); + if (compatibleVersion == null) return; + + //Show notification + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + const AndroidInitializationSettings androidInitializationSettings = + AndroidInitializationSettings('drawable/ic_logo'); + const InitializationSettings initializationSettings = + InitializationSettings(android: androidInitializationSettings); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( + 'freezerupdates', 'Freezer Updates'.i18n, + channelDescription: 'Freezer Updates'.i18n); + NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); + await flutterLocalNotificationsPlugin.show( + 0, 'New update available!'.i18n, 'Update to latest version in the settings.'.i18n, notificationDetails); + } +} + +class FreezerVersion { + String version; + String changelog; + List downloads; + + FreezerVersion({required this.version, required this.changelog, required this.downloads}); + + factory FreezerVersion.fromJson(Map data) => FreezerVersion( + version: data['version'], + changelog: data['changelog'], + downloads: data['downloads'].map((d) => FreezerDownload.fromJson(d)).toList()); +} + +class FreezerDownload { + String version; + String directUrl; + + FreezerDownload({required this.version, required this.directUrl}); + + factory FreezerDownload.fromJson(Map data) => + FreezerDownload(version: data['version'], directUrl: data['links'].first['url']); +} diff --git a/lib/utils/blowfish.dart b/lib/utils/blowfish.dart new file mode 100644 index 0000000..d22a8c5 --- /dev/null +++ b/lib/utils/blowfish.dart @@ -0,0 +1,1170 @@ +import 'dart:typed_data'; + +class Blowfish { + static const BLOCK_SIZE = 8; + + /* + ============================================================= + The hex digits of pi, arranged as four s_boxes & one p_array, + as per the Blowfish default. These have passed muster w/ Eric + Young's set of test vectors. Enjoy. -Mike Schaudies + ============================================================= + */ + + // P-array: Permutation boxes used in the Blowfish algorithm + List pArray = [ + 0x243f6a88, + 0x85a308d3, + 0x13198a2e, + 0x03707344, + 0xa4093822, + 0x299f31d0, + 0x082efa98, + 0xec4e6c89, + 0x452821e6, + 0x38d01377, + 0xbe5466cf, + 0x34e90c6c, + 0xc0ac29b7, + 0xc97c50dd, + 0x3f84d5b5, + 0xb5470917, + 0x9216d5d9, + 0x8979fb1b + ]; + + // S-boxes: Substitution boxes used in the Blowfish algorithm + List> sBoxes = [ + [ + 0xd1310ba6, + 0x98dfb5ac, + 0x2ffd72db, + 0xd01adfb7, + 0xb8e1afed, + 0x6a267e96, + 0xba7c9045, + 0xf12c7f99, + 0x24a19947, + 0xb3916cf7, + 0x0801f2e2, + 0x858efc16, + 0x636920d8, + 0x71574e69, + 0xa458fea3, + 0xf4933d7e, + 0x0d95748f, + 0x728eb658, + 0x718bcd58, + 0x82154aee, + 0x7b54a41d, + 0xc25a59b5, + 0x9c30d539, + 0x2af26013, + 0xc5d1b023, + 0x286085f0, + 0xca417918, + 0xb8db38ef, + 0x8e79dcb0, + 0x603a180e, + 0x6c9e0e8b, + 0xb01e8a3e, + 0xd71577c1, + 0xbd314b27, + 0x78af2fda, + 0x55605c60, + 0xe65525f3, + 0xaa55ab94, + 0x57489862, + 0x63e81440, + 0x55ca396a, + 0x2aab10b6, + 0xb4cc5c34, + 0x1141e8ce, + 0xa15486af, + 0x7c72e993, + 0xb3ee1411, + 0x636fbc2a, + 0x2ba9c55d, + 0x741831f6, + 0xce5c3e16, + 0x9b87931e, + 0xafd6ba33, + 0x6c24cf5c, + 0x7a325381, + 0x28958677, + 0x3b8f4898, + 0x6b4bb9af, + 0xc4bfe81b, + 0x66282193, + 0x61d809cc, + 0xfb21a991, + 0x487cac60, + 0x5dec8032, + 0xef845d5d, + 0xe98575b1, + 0xdc262302, + 0xeb651b88, + 0x23893e81, + 0xd396acc5, + 0x0f6d6ff3, + 0x83f44239, + 0x2e0b4482, + 0xa4842004, + 0x69c8f04a, + 0x9e1f9b5e, + 0x21c66842, + 0xf6e96c9a, + 0x670c9c61, + 0xabd388f0, + 0x6a51a0d2, + 0xd8542f68, + 0x960fa728, + 0xab5133a3, + 0x6eef0b6c, + 0x137a3be4, + 0xba3bf050, + 0x7efb2a98, + 0xa1f1651d, + 0x39af0176, + 0x66ca593e, + 0x82430e88, + 0x8cee8619, + 0x456f9fb4, + 0x7d84a5c3, + 0x3b8b5ebe, + 0xe06f75d8, + 0x85c12073, + 0x401a449f, + 0x56c16aa6, + 0x4ed3aa62, + 0x363f7706, + 0x1bfedf72, + 0x429b023d, + 0x37d0d724, + 0xd00a1248, + 0xdb0fead3, + 0x49f1c09b, + 0x075372c9, + 0x80991b7b, + 0x25d479d8, + 0xf6e8def7, + 0xe3fe501a, + 0xb6794c3b, + 0x976ce0bd, + 0x04c006ba, + 0xc1a94fb6, + 0x409f60c4, + 0x5e5c9ec2, + 0x196a2463, + 0x68fb6faf, + 0x3e6c53b5, + 0x1339b2eb, + 0x3b52ec6f, + 0x6dfc511f, + 0x9b30952c, + 0xcc814544, + 0xaf5ebd09, + 0xbee3d004, + 0xde334afd, + 0x660f2807, + 0x192e4bb3, + 0xc0cba857, + 0x45c8740f, + 0xd20b5f39, + 0xb9d3fbdb, + 0x5579c0bd, + 0x1a60320a, + 0xd6a100c6, + 0x402c7279, + 0x679f25fe, + 0xfb1fa3cc, + 0x8ea5e9f8, + 0xdb3222f8, + 0x3c7516df, + 0xfd616b15, + 0x2f501ec8, + 0xad0552ab, + 0x323db5fa, + 0xfd238760, + 0x53317b48, + 0x3e00df82, + 0x9e5c57bb, + 0xca6f8ca0, + 0x1a87562e, + 0xdf1769db, + 0xd542a8f6, + 0x287effc3, + 0xac6732c6, + 0x8c4f5573, + 0x695b27b0, + 0xbbca58c8, + 0xe1ffa35d, + 0xb8f011a0, + 0x10fa3d98, + 0xfd2183b8, + 0x4afcb56c, + 0x2dd1d35b, + 0x9a53e479, + 0xb6f84565, + 0xd28e49bc, + 0x4bfb9790, + 0xe1ddf2da, + 0xa4cb7e33, + 0x62fb1341, + 0xcee4c6e8, + 0xef20cada, + 0x36774c01, + 0xd07e9efe, + 0x2bf11fb4, + 0x95dbda4d, + 0xae909198, + 0xeaad8e71, + 0x6b93d5a0, + 0xd08ed1d0, + 0xafc725e0, + 0x8e3c5b2f, + 0x8e7594b7, + 0x8ff6e2fb, + 0xf2122b64, + 0x8888b812, + 0x900df01c, + 0x4fad5ea0, + 0x688fc31c, + 0xd1cff191, + 0xb3a8c1ad, + 0x2f2f2218, + 0xbe0e1777, + 0xea752dfe, + 0x8b021fa1, + 0xe5a0cc0f, + 0xb56f74e8, + 0x18acf3d6, + 0xce89e299, + 0xb4a84fe0, + 0xfd13e0b7, + 0x7cc43b81, + 0xd2ada8d9, + 0x165fa266, + 0x80957705, + 0x93cc7314, + 0x211a1477, + 0xe6ad2065, + 0x77b5fa86, + 0xc75442f5, + 0xfb9d35cf, + 0xebcdaf0c, + 0x7b3e89a0, + 0xd6411bd3, + 0xae1e7e49, + 0x00250e2d, + 0x2071b35e, + 0x226800bb, + 0x57b8e0af, + 0x2464369b, + 0xf009b91e, + 0x5563911d, + 0x59dfa6aa, + 0x78c14389, + 0xd95a537f, + 0x207d5ba2, + 0x02e5b9c5, + 0x83260376, + 0x6295cfa9, + 0x11c81968, + 0x4e734a41, + 0xb3472dca, + 0x7b14a94a, + 0x1b510052, + 0x9a532915, + 0xd60f573f, + 0xbc9bc6e4, + 0x2b60a476, + 0x81e67400, + 0x08ba6fb5, + 0x571be91f, + 0xf296ec6b, + 0x2a0dd915, + 0xb6636521, + 0xe7b9f9b6, + 0xff34052e, + 0xc5855664, + 0x53b02d5d, + 0xa99f8fa1, + 0x08ba4799, + 0x6e85076a + ], + [ + 0x4b7a70e9, + 0xb5b32944, + 0xdb75092e, + 0xc4192623, + 0xad6ea6b0, + 0x49a7df7d, + 0x9cee60b8, + 0x8fedb266, + 0xecaa8c71, + 0x699a17ff, + 0x5664526c, + 0xc2b19ee1, + 0x193602a5, + 0x75094c29, + 0xa0591340, + 0xe4183a3e, + 0x3f54989a, + 0x5b429d65, + 0x6b8fe4d6, + 0x99f73fd6, + 0xa1d29c07, + 0xefe830f5, + 0x4d2d38e6, + 0xf0255dc1, + 0x4cdd2086, + 0x8470eb26, + 0x6382e9c6, + 0x021ecc5e, + 0x09686b3f, + 0x3ebaefc9, + 0x3c971814, + 0x6b6a70a1, + 0x687f3584, + 0x52a0e286, + 0xb79c5305, + 0xaa500737, + 0x3e07841c, + 0x7fdeae5c, + 0x8e7d44ec, + 0x5716f2b8, + 0xb03ada37, + 0xf0500c0d, + 0xf01c1f04, + 0x0200b3ff, + 0xae0cf51a, + 0x3cb574b2, + 0x25837a58, + 0xdc0921bd, + 0xd19113f9, + 0x7ca92ff6, + 0x94324773, + 0x22f54701, + 0x3ae5e581, + 0x37c2dadc, + 0xc8b57634, + 0x9af3dda7, + 0xa9446146, + 0x0fd0030e, + 0xecc8c73e, + 0xa4751e41, + 0xe238cd99, + 0x3bea0e2f, + 0x3280bba1, + 0x183eb331, + 0x4e548b38, + 0x4f6db908, + 0x6f420d03, + 0xf60a04bf, + 0x2cb81290, + 0x24977c79, + 0x5679b072, + 0xbcaf89af, + 0xde9a771f, + 0xd9930810, + 0xb38bae12, + 0xdccf3f2e, + 0x5512721f, + 0x2e6b7124, + 0x501adde6, + 0x9f84cd87, + 0x7a584718, + 0x7408da17, + 0xbc9f9abc, + 0xe94b7d8c, + 0xec7aec3a, + 0xdb851dfa, + 0x63094366, + 0xc464c3d2, + 0xef1c1847, + 0x3215d908, + 0xdd433b37, + 0x24c2ba16, + 0x12a14d43, + 0x2a65c451, + 0x50940002, + 0x133ae4dd, + 0x71dff89e, + 0x10314e55, + 0x81ac77d6, + 0x5f11199b, + 0x043556f1, + 0xd7a3c76b, + 0x3c11183b, + 0x5924a509, + 0xf28fe6ed, + 0x97f1fbfa, + 0x9ebabf2c, + 0x1e153c6e, + 0x86e34570, + 0xeae96fb1, + 0x860e5e0a, + 0x5a3e2ab3, + 0x771fe71c, + 0x4e3d06fa, + 0x2965dcb9, + 0x99e71d0f, + 0x803e89d6, + 0x5266c825, + 0x2e4cc978, + 0x9c10b36a, + 0xc6150eba, + 0x94e2ea78, + 0xa5fc3c53, + 0x1e0a2df4, + 0xf2f74ea7, + 0x361d2b3d, + 0x1939260f, + 0x19c27960, + 0x5223a708, + 0xf71312b6, + 0xebadfe6e, + 0xeac31f66, + 0xe3bc4595, + 0xa67bc883, + 0xb17f37d1, + 0x018cff28, + 0xc332ddef, + 0xbe6c5aa5, + 0x65582185, + 0x68ab9802, + 0xeecea50f, + 0xdb2f953b, + 0x2aef7dad, + 0x5b6e2f84, + 0x1521b628, + 0x29076170, + 0xecdd4775, + 0x619f1510, + 0x13cca830, + 0xeb61bd96, + 0x0334fe1e, + 0xaa0363cf, + 0xb5735c90, + 0x4c70a239, + 0xd59e9e0b, + 0xcbaade14, + 0xeecc86bc, + 0x60622ca7, + 0x9cab5cab, + 0xb2f3846e, + 0x648b1eaf, + 0x19bdf0ca, + 0xa02369b9, + 0x655abb50, + 0x40685a32, + 0x3c2ab4b3, + 0x319ee9d5, + 0xc021b8f7, + 0x9b540b19, + 0x875fa099, + 0x95f7997e, + 0x623d7da8, + 0xf837889a, + 0x97e32d77, + 0x11ed935f, + 0x16681281, + 0x0e358829, + 0xc7e61fd6, + 0x96dedfa1, + 0x7858ba99, + 0x57f584a5, + 0x1b227263, + 0x9b83c3ff, + 0x1ac24696, + 0xcdb30aeb, + 0x532e3054, + 0x8fd948e4, + 0x6dbc3128, + 0x58ebf2ef, + 0x34c6ffea, + 0xfe28ed61, + 0xee7c3c73, + 0x5d4a14d9, + 0xe864b7e3, + 0x42105d14, + 0x203e13e0, + 0x45eee2b6, + 0xa3aaabea, + 0xdb6c4f15, + 0xfacb4fd0, + 0xc742f442, + 0xef6abbb5, + 0x654f3b1d, + 0x41cd2105, + 0xd81e799e, + 0x86854dc7, + 0xe44b476a, + 0x3d816250, + 0xcf62a1f2, + 0x5b8d2646, + 0xfc8883a0, + 0xc1c7b6a3, + 0x7f1524c3, + 0x69cb7492, + 0x47848a0b, + 0x5692b285, + 0x095bbf00, + 0xad19489d, + 0x1462b174, + 0x23820e00, + 0x58428d2a, + 0x0c55f5ea, + 0x1dadf43e, + 0x233f7061, + 0x3372f092, + 0x8d937e41, + 0xd65fecf1, + 0x6c223bdb, + 0x7cde3759, + 0xcbee7460, + 0x4085f2a7, + 0xce77326e, + 0xa6078084, + 0x19f8509e, + 0xe8efd855, + 0x61d99735, + 0xa969a7aa, + 0xc50c06c2, + 0x5a04abfc, + 0x800bcadc, + 0x9e447a2e, + 0xc3453484, + 0xfdd56705, + 0x0e1e9ec9, + 0xdb73dbd3, + 0x105588cd, + 0x675fda79, + 0xe3674340, + 0xc5c43465, + 0x713e38d8, + 0x3d28f89e, + 0xf16dff20, + 0x153e21e7, + 0x8fb03d4a, + 0xe6e39f2b, + 0xdb83adf7 + ], + [ + 0xe93d5a68, + 0x948140f7, + 0xf64c261c, + 0x94692934, + 0x411520f7, + 0x7602d4f7, + 0xbcf46b2e, + 0xd4a20068, + 0xd4082471, + 0x3320f46a, + 0x43b7d4b7, + 0x500061af, + 0x1e39f62e, + 0x97244546, + 0x14214f74, + 0xbf8b8840, + 0x4d95fc1d, + 0x96b591af, + 0x70f4ddd3, + 0x66a02f45, + 0xbfbc09ec, + 0x03bd9785, + 0x7fac6dd0, + 0x31cb8504, + 0x96eb27b3, + 0x55fd3941, + 0xda2547e6, + 0xabca0a9a, + 0x28507825, + 0x530429f4, + 0x0a2c86da, + 0xe9b66dfb, + 0x68dc1462, + 0xd7486900, + 0x680ec0a4, + 0x27a18dee, + 0x4f3ffea2, + 0xe887ad8c, + 0xb58ce006, + 0x7af4d6b6, + 0xaace1e7c, + 0xd3375fec, + 0xce78a399, + 0x406b2a42, + 0x20fe9e35, + 0xd9f385b9, + 0xee39d7ab, + 0x3b124e8b, + 0x1dc9faf7, + 0x4b6d1856, + 0x26a36631, + 0xeae397b2, + 0x3a6efa74, + 0xdd5b4332, + 0x6841e7f7, + 0xca7820fb, + 0xfb0af54e, + 0xd8feb397, + 0x454056ac, + 0xba489527, + 0x55533a3a, + 0x20838d87, + 0xfe6ba9b7, + 0xd096954b, + 0x55a867bc, + 0xa1159a58, + 0xcca92963, + 0x99e1db33, + 0xa62a4a56, + 0x3f3125f9, + 0x5ef47e1c, + 0x9029317c, + 0xfdf8e802, + 0x04272f70, + 0x80bb155c, + 0x05282ce3, + 0x95c11548, + 0xe4c66d22, + 0x48c1133f, + 0xc70f86dc, + 0x07f9c9ee, + 0x41041f0f, + 0x404779a4, + 0x5d886e17, + 0x325f51eb, + 0xd59bc0d1, + 0xf2bcc18f, + 0x41113564, + 0x257b7834, + 0x602a9c60, + 0xdff8e8a3, + 0x1f636c1b, + 0x0e12b4c2, + 0x02e1329e, + 0xaf664fd1, + 0xcad18115, + 0x6b2395e0, + 0x333e92e1, + 0x3b240b62, + 0xeebeb922, + 0x85b2a20e, + 0xe6ba0d99, + 0xde720c8c, + 0x2da2f728, + 0xd0127845, + 0x95b794fd, + 0x647d0862, + 0xe7ccf5f0, + 0x5449a36f, + 0x877d48fa, + 0xc39dfd27, + 0xf33e8d1e, + 0x0a476341, + 0x992eff74, + 0x3a6f6eab, + 0xf4f8fd37, + 0xa812dc60, + 0xa1ebddf8, + 0x991be14c, + 0xdb6e6b0d, + 0xc67b5510, + 0x6d672c37, + 0x2765d43b, + 0xdcd0e804, + 0xf1290dc7, + 0xcc00ffa3, + 0xb5390f92, + 0x690fed0b, + 0x667b9ffb, + 0xcedb7d9c, + 0xa091cf0b, + 0xd9155ea3, + 0xbb132f88, + 0x515bad24, + 0x7b9479bf, + 0x763bd6eb, + 0x37392eb3, + 0xcc115979, + 0x8026e297, + 0xf42e312d, + 0x6842ada7, + 0xc66a2b3b, + 0x12754ccc, + 0x782ef11c, + 0x6a124237, + 0xb79251e7, + 0x06a1bbe6, + 0x4bfb6350, + 0x1a6b1018, + 0x11caedfa, + 0x3d25bdd8, + 0xe2e1c3c9, + 0x44421659, + 0x0a121386, + 0xd90cec6e, + 0xd5abea2a, + 0x64af674e, + 0xda86a85f, + 0xbebfe988, + 0x64e4c3fe, + 0x9dbc8057, + 0xf0f7c086, + 0x60787bf8, + 0x6003604d, + 0xd1fd8346, + 0xf6381fb0, + 0x7745ae04, + 0xd736fccc, + 0x83426b33, + 0xf01eab71, + 0xb0804187, + 0x3c005e5f, + 0x77a057be, + 0xbde8ae24, + 0x55464299, + 0xbf582e61, + 0x4e58f48f, + 0xf2ddfda2, + 0xf474ef38, + 0x8789bdc2, + 0x5366f9c3, + 0xc8b38e74, + 0xb475f255, + 0x46fcd9b9, + 0x7aeb2661, + 0x8b1ddf84, + 0x846a0e79, + 0x915f95e2, + 0x466e598e, + 0x20b45770, + 0x8cd55591, + 0xc902de4c, + 0xb90bace1, + 0xbb8205d0, + 0x11a86248, + 0x7574a99e, + 0xb77f19b6, + 0xe0a9dc09, + 0x662d09a1, + 0xc4324633, + 0xe85a1f02, + 0x09f0be8c, + 0x4a99a025, + 0x1d6efe10, + 0x1ab93d1d, + 0x0ba5a4df, + 0xa186f20f, + 0x2868f169, + 0xdcb7da83, + 0x573906fe, + 0xa1e2ce9b, + 0x4fcd7f52, + 0x50115e01, + 0xa70683fa, + 0xa002b5c4, + 0x0de6d027, + 0x9af88c27, + 0x773f8641, + 0xc3604c06, + 0x61a806b5, + 0xf0177a28, + 0xc0f586e0, + 0x006058aa, + 0x30dc7d62, + 0x11e69ed7, + 0x2338ea63, + 0x53c2dd94, + 0xc2c21634, + 0xbbcbee56, + 0x90bcb6de, + 0xebfc7da1, + 0xce591d76, + 0x6f05e409, + 0x4b7c0188, + 0x39720a3d, + 0x7c927c24, + 0x86e3725f, + 0x724d9db9, + 0x1ac15bb4, + 0xd39eb8fc, + 0xed545578, + 0x08fca5b5, + 0xd83d7cd3, + 0x4dad0fc4, + 0x1e50ef5e, + 0xb161e6f8, + 0xa28514d9, + 0x6c51133c, + 0x6fd5c7e7, + 0x56e14ec4, + 0x362abfce, + 0xddc6c837, + 0xd79a3234, + 0x92638212, + 0x670efa8e, + 0x406000e0, + ], + [ + 0x3a39ce37, + 0xd3faf5cf, + 0xabc27737, + 0x5ac52d1b, + 0x5cb0679e, + 0x4fa33742, + 0xd3822740, + 0x99bc9bbe, + 0xd5118e9d, + 0xbf0f7315, + 0xd62d1c7e, + 0xc700c47b, + 0xb78c1b6b, + 0x21a19045, + 0xb26eb1be, + 0x6a366eb4, + 0x5748ab2f, + 0xbc946e79, + 0xc6a376d2, + 0x6549c2c8, + 0x530ff8ee, + 0x468dde7d, + 0xd5730a1d, + 0x4cd04dc6, + 0x2939bbdb, + 0xa9ba4650, + 0xac9526e8, + 0xbe5ee304, + 0xa1fad5f0, + 0x6a2d519a, + 0x63ef8ce2, + 0x9a86ee22, + 0xc089c2b8, + 0x43242ef6, + 0xa51e03aa, + 0x9cf2d0a4, + 0x83c061ba, + 0x9be96a4d, + 0x8fe51550, + 0xba645bd6, + 0x2826a2f9, + 0xa73a3ae1, + 0x4ba99586, + 0xef5562e9, + 0xc72fefd3, + 0xf752f7da, + 0x3f046f69, + 0x77fa0a59, + 0x80e4a915, + 0x87b08601, + 0x9b09e6ad, + 0x3b3ee593, + 0xe990fd5a, + 0x9e34d797, + 0x2cf0b7d9, + 0x022b8b51, + 0x96d5ac3a, + 0x017da67d, + 0xd1cf3ed6, + 0x7c7d2d28, + 0x1f9f25cf, + 0xadf2b89b, + 0x5ad6b472, + 0x5a88f54c, + 0xe029ac71, + 0xe019a5e6, + 0x47b0acfd, + 0xed93fa9b, + 0xe8d3c48d, + 0x283b57cc, + 0xf8d56629, + 0x79132e28, + 0x785f0191, + 0xed756055, + 0xf7960e44, + 0xe3d35e8c, + 0x15056dd4, + 0x88f46dba, + 0x03a16125, + 0x0564f0bd, + 0xc3eb9e15, + 0x3c9057a2, + 0x97271aec, + 0xa93a072a, + 0x1b3f6d9b, + 0x1e6321f5, + 0xf59c66fb, + 0x26dcf319, + 0x7533d928, + 0xb155fdf5, + 0x03563482, + 0x8aba3cbb, + 0x28517711, + 0xc20ad9f8, + 0xabcc5167, + 0xccad925f, + 0x4de81751, + 0x3830dc8e, + 0x379d5862, + 0x9320f991, + 0xea7a90c2, + 0xfb3e7bce, + 0x5121ce64, + 0x774fbe32, + 0xa8b6e37e, + 0xc3293d46, + 0x48de5369, + 0x6413e680, + 0xa2ae0810, + 0xdd6db224, + 0x69852dfd, + 0x09072166, + 0xb39a460a, + 0x6445c0dd, + 0x586cdecf, + 0x1c20c8ae, + 0x5bbef7dd, + 0x1b588d40, + 0xccd2017f, + 0x6bb4e3bb, + 0xdda26a7e, + 0x3a59ff45, + 0x3e350a44, + 0xbcb4cdd5, + 0x72eacea8, + 0xfa6484bb, + 0x8d6612ae, + 0xbf3c6f47, + 0xd29be463, + 0x542f5d9e, + 0xaec2771b, + 0xf64e6370, + 0x740e0d8d, + 0xe75b1357, + 0xf8721671, + 0xaf537d5d, + 0x4040cb08, + 0x4eb4e2cc, + 0x34d2466a, + 0x0115af84, + 0xe1b00428, + 0x95983a1d, + 0x06b89fb4, + 0xce6ea048, + 0x6f3f3b82, + 0x3520ab82, + 0x011a1d4b, + 0x277227f8, + 0x611560b1, + 0xe7933fdc, + 0xbb3a792b, + 0x344525bd, + 0xa08839e1, + 0x51ce794b, + 0x2f32c9b7, + 0xa01fbac9, + 0xe01cc87e, + 0xbcc7d1f6, + 0xcf0111c3, + 0xa1e8aac7, + 0x1a908749, + 0xd44fbd9a, + 0xd0dadecb, + 0xd50ada38, + 0x0339c32a, + 0xc6913667, + 0x8df9317c, + 0xe0b12b4f, + 0xf79e59b7, + 0x43f5bb3a, + 0xf2d519ff, + 0x27d9459c, + 0xbf97222c, + 0x15e6fc2a, + 0x0f91fc71, + 0x9b941525, + 0xfae59361, + 0xceb69ceb, + 0xc2a86459, + 0x12baa8d1, + 0xb6c1075e, + 0xe3056a0c, + 0x10d25065, + 0xcb03a442, + 0xe0ec6e0e, + 0x1698db3b, + 0x4c98a0be, + 0x3278e964, + 0x9f1f9532, + 0xe0d392df, + 0xd3a0342b, + 0x8971f21e, + 0x1b0a7441, + 0x4ba3348c, + 0xc5be7120, + 0xc37632d8, + 0xdf359f8d, + 0x9b992f2e, + 0xe60b6f47, + 0x0fe3f11d, + 0xe54cda54, + 0x1edad891, + 0xce6279cf, + 0xcd3e7e6f, + 0x1618b166, + 0xfd2c1d05, + 0x848fd2c5, + 0xf6fb2299, + 0xf523f357, + 0xa6327623, + 0x93a83531, + 0x56cccd02, + 0xacf08162, + 0x5a75ebb5, + 0x6e163697, + 0x88d273cc, + 0xde966292, + 0x81b949d0, + 0x4c50901b, + 0x71c65614, + 0xe6c6c7bd, + 0x327a140a, + 0x45e1d006, + 0xc3f27b9a, + 0xc9aa53fd, + 0x62a80f00, + 0xbb25bfe2, + 0x35bdd2f6, + 0x71126905, + 0xb2040222, + 0xb6cbcf7c, + 0xcd769c2b, + 0x53113ec0, + 0x1640e3d3, + 0x38abbd60, + 0x2547adf0, + 0xba38209c, + 0xf746ce76, + 0x77afa1c5, + 0x20756060, + 0x85cbfe4e, + 0x8ae88dd8, + 0x7aaaf9b0, + 0x4cf9aa7e, + 0x1948c25c, + 0x02fb8a8c, + 0x01c36ae4, + 0xd6ebe1f9, + 0x90d4f869, + 0xa65cdea0, + 0x3f09252d, + 0xc208e69f, + 0xb74e6132, + 0xce77e25b, + 0x578fdfe3, + 0x3ac372e6 + ] + ]; + + Blowfish(List key) { + _setKey(key); + } + + void _setKey(List key) { + int keyLength = key.length; + int j = 0; + for (int i = 0; i < 18; i++) { + int data = 0; + for (int k = 0; k < 4; k++) { + data = (data << 8) | key[j]; + j = (j + 1) % keyLength; + } + pArray[i] ^= data; + } + + Uint32List lr = Uint32List(2); + for (int i = 0; i < 18; i += 2) { + _encryptBlock(lr); + pArray[i] = lr[0]; + pArray[i + 1] = lr[1]; + } + + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 256; j += 2) { + _encryptBlock(lr); + sBoxes[i][j] = lr[0]; + sBoxes[i][j + 1] = lr[1]; + } + } + } + + void _encryptBlock(Uint32List lr) { + int left = lr[0]; + int right = lr[1]; + + for (int i = 0; i < 16; i++) { + left ^= pArray[i]; + right ^= _f(left); + int temp = left; + left = right; + right = temp; + } + + int swap = left; + left = right; + right = swap; + + right ^= pArray[16]; + left ^= pArray[17]; + + lr[0] = left; + lr[1] = right; + } + + int _f(int x) { + return ((sBoxes[0][(x >> 24) & 0xff] + sBoxes[1][(x >> 16) & 0xff]) ^ sBoxes[2][(x >> 8) & 0xff]) + + sBoxes[3][x & 0xff]; + } + + // Decrypts the given data using the provided initialization vector (IV) + // using the Cipher-Block Chaining (CBC) mode of operation. + // + // Parameters: + // data: The data to decrypt as a Uint8List. + // iv: The initialization vector (IV) as a Uint8List. + // + // Returns: + // A Uint8List containing the decrypted data. + Uint8List decrypt(Uint8List data, Uint8List iv) { + if (data.length % BLOCK_SIZE != 0) { + throw Exception('Invalid data length, must be a multiple of $BLOCK_SIZE'); + } + + Uint8List result = Uint8List(data.length); + Uint32List lr = Uint32List(2); + Uint8List block = Uint8List(BLOCK_SIZE); + + int prevLeft = ByteData.view(iv.buffer).getUint32(0, Endian.big); + int prevRight = ByteData.view(iv.buffer).getUint32(4, Endian.big); + + for (int i = 0; i < data.length; i += BLOCK_SIZE) { + block.setRange(0, BLOCK_SIZE, data, i); + + lr[0] = ByteData.view(block.buffer).getUint32(0, Endian.big) ^ prevLeft; + lr[1] = ByteData.view(block.buffer).getUint32(4, Endian.big) ^ prevRight; + + _encryptBlock(lr); + + result.setRange(i, i + 4, lr.buffer.asUint8List(0, 4)); + result.setRange(i + 4, i + 8, lr.buffer.asUint8List(4, 4)); + + prevLeft = ByteData.view(block.buffer).getUint32(0, Endian.big); + prevRight = ByteData.view(block.buffer).getUint32(4, Endian.big); + } + + return result; + } +} diff --git a/lib/utils/cookie_manager.dart b/lib/utils/cookie_manager.dart new file mode 100644 index 0000000..38f32b2 --- /dev/null +++ b/lib/utils/cookie_manager.dart @@ -0,0 +1,51 @@ +import 'package:http/http.dart' as http; + +class CookieManager { + Map cookieHeader = {}; + Map cookies = {}; + + void reset() { + cookieHeader = {}; + cookies = {}; + } + + void updateCookie(http.Response response) { + Map> rawCookies = response.headersSplitValues; + List? setCookies = rawCookies['set-cookie']; + + if (setCookies?.isEmpty ?? true) return; + + for (String setCookie in setCookies!) { + var cookies = setCookie.split(';'); + + for (var cookie in cookies) { + _setCookie(cookie); + } + } + + cookieHeader['cookie'] = _generateCookieHeader(); + } + + void _setCookie(String rawCookie) { + if (rawCookie.isNotEmpty) { + int idx = rawCookie.indexOf('='); + if (idx >= 0) { + var key = rawCookie.substring(0, idx).trim(); + var value = rawCookie.substring(idx + 1).trim(); + if (key == 'path' || key == 'expires' || key == 'domain' || key == 'sameSite') return; + cookies[key] = value; + } + } + } + + String _generateCookieHeader() { + String cookie = ''; + + for (var key in cookies.keys) { + if (cookie.isNotEmpty) cookie += ';'; + cookie += key + '=' + cookies[key]!; + } + + return cookie; + } +} diff --git a/lib/utils/env.dart b/lib/utils/env.dart new file mode 100644 index 0000000..717500e --- /dev/null +++ b/lib/utils/env.dart @@ -0,0 +1,19 @@ +// lib/env/env.dart +import 'package:envied/envied.dart'; + +part 'env.g.dart'; + +@Envied(path: 'lib/.env') +abstract class Env { + // Deezer + @EnviedField(varName: 'deezerClientId', obfuscate: true) + static final String deezerClientId = _Env.deezerClientId; + @EnviedField(varName: 'deezerClientSecret', obfuscate: true) + static final String deezerClientSecret = _Env.deezerClientSecret; + + // LastFM + @EnviedField(varName: 'lastFmApiKey', obfuscate: true) + static final String lastFmApiKey = _Env.lastFmApiKey; + @EnviedField(varName: 'lastFmApiSecret', obfuscate: true) + static final String lastFmApiSecret = _Env.lastFmApiSecret; +} diff --git a/lib/utils/file_utils.dart b/lib/utils/file_utils.dart new file mode 100644 index 0000000..6c98048 --- /dev/null +++ b/lib/utils/file_utils.dart @@ -0,0 +1,65 @@ +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class FileUtils { + // Just in case manageExternalStorage should be needed + static Future checkExternalStoragePermissions( + Future Function() showDialogCallback) async { + PermissionStatus status = PermissionStatus.denied; + bool permissionGranted = false; + final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); + final AndroidDeviceInfo info = await deviceInfoPlugin.androidInfo; + + // Starting at compileSdkVersion 30, storage permissions changed + // MANAGE_EXTERNAL_STORAGE was introduced in API 30, READ_EXTERNAL_STORAGE & WRITE_EXTERNAL_STORAGE deprecated + // READ_EXTERNAL_STORAGE & WRITE_EXTERNAL_STORAGE where removed in API 33 + // Instead, MANAGE_EXTERNAL_STORAGE is required for any access outside apps own storage. + if ((info.version.sdkInt) < 30) { + status = await Permission.storage.request(); + } else { + status = await Permission.manageExternalStorage.status; + if (!status.isGranted) { + if (await showDialogCallback()) { + status = await Permission.manageExternalStorage.request(); + } else { + return false; + } + } + } + + if (status.isGranted || status.isLimited) { + permissionGranted = true; + } else if (status.isPermanentlyDenied && await showDialogCallback()) { + permissionGranted = await openAppSettings(); + } + + return permissionGranted; + } + + static Future checkStoragePermission() async { + bool permissionGranted = false; + DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); + AndroidDeviceInfo android = await deviceInfoPlugin.androidInfo; + if (android.version.sdkInt < 30) { + if (await Permission.storage.request().isGranted) { + permissionGranted = true; + } else if (await Permission.storage.request().isPermanentlyDenied) { + permissionGranted = await openAppSettings(); + } else if (await Permission.storage.request().isDenied) { + permissionGranted = false; + } + } else { + /* In case we want to access shared audio from other apps + if (await Permission.audio.request().isGranted) { + permissionGranted = true; + } else if (await Permission.audio.request().isPermanentlyDenied) { + await openAppSettings(); + } else if (await Permission.audio.request().isDenied) { + permissionGranted = false; + }*/ + // From sdk version 33 (android 13) and up, storage permissions are implicitly granted for own files + permissionGranted = true; + } + return permissionGranted; + } +} diff --git a/lib/utils/logging.dart b/lib/utils/logging.dart new file mode 100644 index 0000000..23007fd --- /dev/null +++ b/lib/utils/logging.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:developer'; +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; + +class LogQueueManager { + final File logFile; + final Queue _logQueue = Queue(); + bool _isWriting = false; + + LogQueueManager(this.logFile); + + void enqueue(String logEntry) { + _logQueue.add(logEntry); + _processQueue(); + } + + Future _processQueue() async { + if (_isWriting) return; + _isWriting = true; + + while (_logQueue.isNotEmpty) { + String logEntry = _logQueue.removeFirst(); + log(logEntry); + try { + await logFile.writeAsString(logEntry, mode: FileMode.append); + } catch (e) { + log('Error writing to log file: $e'); + } + } + + _isWriting = false; + } +} + +Future initializeLogging() async { + final String path = + p.join((await getExternalStorageDirectory())!.path, 'refreezer.log'); + final File logFile = File(path); + + if (!await logFile.exists()) { + await logFile.create(recursive: true); + } + + // Clear old session data + await logFile.writeAsString(''); + Logger.root.level = Level.ALL; + + final logQueueManager = LogQueueManager(logFile); + + Logger.root.onRecord.listen((record) { + final logMessage = _formatLogMessage(record); + logQueueManager.enqueue(logMessage); + }); +} + +String _formatLogMessage(LogRecord record) { + final buffer = StringBuffer(); + buffer.write('${record.level.name}: ${record.time}: ${record.message}\n'); + + if (record.error != null) { + buffer.write('Error: ${record.error}\n'); + } + + if (record.stackTrace != null) { + buffer.write('Stack Trace: ${record.stackTrace}\n'); + } + + return buffer.toString(); +} diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart new file mode 100644 index 0000000..6daf269 --- /dev/null +++ b/lib/utils/math_utils.dart @@ -0,0 +1,19 @@ +import 'dart:math'; + +/// Computes `sqrt(x^2 + y^2)` without under/overflow +num hypot(num x, num y) { + var first = x.abs(); + var second = y.abs(); + + if (y > x) { + first = y.abs(); + second = x.abs(); + } + + if (first == 0.0) { + return second; + } + + final t = second / first; + return first * sqrt(1 + t * t); +} diff --git a/lib/utils/mediaitem_converter.dart b/lib/utils/mediaitem_converter.dart new file mode 100644 index 0000000..86e26e4 --- /dev/null +++ b/lib/utils/mediaitem_converter.dart @@ -0,0 +1,39 @@ +import 'package:audio_service/audio_service.dart'; + +// Separate converter since MediaItem.toJson() MediaItem.fromJson() are removed in audio_service v0.18+ +class MediaItemConverter { + static MediaItem mediaItemFromMap(Map json) { + return MediaItem( + id: json['id'], + album: json['album'], + title: json['title'], + artist: json['artist'], + genre: json['genre'], + duration: Duration(milliseconds: json['duration']), + artUri: json['artUri'] != null ? Uri.parse(json['artUri']) : null, + artHeaders: Map.from(json['artHeaders'] ?? {}), + playable: json['playable'], + displayTitle: json['displayTitle'], + displaySubtitle: json['displaySubtitle'], + displayDescription: json['displayDescription'], + rating: null, + extras: Map.from(json['extras']), + ); + } + + static Map mediaItemToMap(MediaItem mi) => { + 'id': mi.id, + 'title': mi.title, + 'album': mi.album, + 'artist': mi.artist, + 'genre': mi.genre, + 'duration': mi.duration?.inMilliseconds, + 'artUri': mi.artUri?.toString(), + 'playable': mi.playable, + 'displayTitle': mi.displayTitle, + 'displaySubtitle': mi.displaySubtitle, + 'displayDescription': mi.displayDescription, + 'rating': null, + 'extras': mi.extras, + }; +} diff --git a/lib/utils/navigator_keys.dart b/lib/utils/navigator_keys.dart new file mode 100644 index 0000000..82856bd --- /dev/null +++ b/lib/utils/navigator_keys.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +final GlobalKey mainNavigatorKey = GlobalKey(); +final GlobalKey customNavigatorKey = GlobalKey(); diff --git a/lib/utils/version.dart b/lib/utils/version.dart new file mode 100644 index 0000000..685cc61 --- /dev/null +++ b/lib/utils/version.dart @@ -0,0 +1,290 @@ +/* Copyright (c) 2021, Matthew Barbour. +All rights reserved. +https://github.com/dartninja/version + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ + +/// Provides version objects to enforce conformance to the Semantic Versioning 2.0 spec. The spec can be read at http://semver.org/ +library version; + +/// Provides immutable storage and comparison of semantic version numbers. +class Version implements Comparable { + static final RegExp _versionRegex = RegExp(r'^([\d.]+)(-([0-9A-Za-z\-.]+))?(\+([0-9A-Za-z\-.]+))?$'); + static final RegExp _buildRegex = RegExp(r'^[0-9A-Za-z\-.]+$'); + static final RegExp _preReleaseRegex = RegExp(r'^[0-9A-Za-z\-]+$'); + + /// The major number of the version, incremented when making breaking changes. + final int major; + + /// The minor number of the version, incremented when adding new functionality in a backwards-compatible manner. + final int minor; + + /// The patch number of the version, incremented when making backwards-compatible bug fixes. + final int patch; + + /// Build information relevant to the version. Does not contribute to sorting. + final String build; + + final List _preRelease; + + /// Indicates that the version is a pre-release. Returns true if preRelease has any segments, otherwise false + bool get isPreRelease => _preRelease.isNotEmpty; + + /// Creates a new instance of [Version]. + /// + /// [major], [minor], and [patch] are all required, all must be greater than 0 and not null, and at least one must be greater than 0. + /// [preRelease] is optional, but if specified must be a [List] of [String] and must not be null. Each element in the list represents one of the period-separated segments of the pre-release information, and may only contain [0-9A-Za-z-]. + /// [build] is optional, but if specified must be a [String]. must contain only [0-9A-Za-z-.], and must not be null. + /// Throws a [FormatException] if the [String] content does not follow the character constraints defined above. + /// Throes an [ArgumentError] if any of the other conditions are violated. + Version(this.major, this.minor, this.patch, {List preRelease = const [], this.build = ''}) + : _preRelease = preRelease { + for (int i = 0; i < _preRelease.length; i++) { + if (_preRelease[i].toString().trim().isEmpty) { + throw ArgumentError('preRelease segments must not be empty'); + } + // Just in case + _preRelease[i] = _preRelease[i].toString(); + if (!_preReleaseRegex.hasMatch(_preRelease[i])) { + throw const FormatException('preRelease segments must only contain [0-9A-Za-z-]'); + } + } + if (build.isNotEmpty && !_buildRegex.hasMatch(build)) { + throw const FormatException('build must only contain [0-9A-Za-z-.]'); + } + + if (major < 0 || minor < 0 || patch < 0) { + throw ArgumentError('Version numbers must be greater than 0'); + } + } + + @override + int get hashCode => toString().hashCode; + + /// Pre-release information segments. + List get preRelease => List.from(_preRelease); + + /// Determines whether the left-hand [Version] represents a lower precedence than the right-hand [Version]. + bool operator <(Object o) => o is Version && _compare(this, o) < 0; + + /// Determines whether the left-hand [Version] represents an equal or lower precedence than the right-hand [Version]. + bool operator <=(Object o) => o is Version && _compare(this, o) <= 0; + + /// Determines whether the left-hand [Version] represents an equal precedence to the right-hand [Version]. + @override + bool operator ==(Object o) => o is Version && _compare(this, o) == 0; + + /// Determines whether the left-hand [Version] represents a greater precedence than the right-hand [Version]. + bool operator >(Object o) => o is Version && _compare(this, o) > 0; + + /// Determines whether the left-hand [Version] represents an equal or greater precedence than the right-hand [Version]. + bool operator >=(Object o) => o is Version && _compare(this, o) >= 0; + + @override + int compareTo(Version? other) { + if (other == null) { + throw ArgumentError.notNull('other'); + } + + return _compare(this, other); + } + + /// Creates a new [Version] with the [major] version number incremented. + /// + /// Also resets the [minor] and [patch] numbers to 0, and clears the [build] and [preRelease] information. + Version incrementMajor() => Version(major + 1, 0, 0); + + /// Creates a new [Version] with the [minor] version number incremented. + /// + /// Also resets the [patch] number to 0, and clears the [build] and [preRelease] information. + Version incrementMinor() => Version(major, minor + 1, 0); + + /// Creates a new [Version] with the [patch] version number incremented. + /// + /// Also clears the [build] and [preRelease] information. + Version incrementPatch() => Version(major, minor, patch + 1); + + /// Creates a new [Version] with the right-most numeric [preRelease] segment incremented. + /// If no numeric segment is found, one will be added with the value "1". + /// + /// If this [Version] is not a pre-release version, an Exception will be thrown. + Version incrementPreRelease() { + if (!isPreRelease) { + throw Exception('Cannot increment pre-release on a non-pre-release [Version]'); + } + var newPreRelease = preRelease; + + var found = false; + for (var i = newPreRelease.length - 1; i >= 0; i--) { + var segment = newPreRelease[i]; + if (Version._isNumeric(segment)) { + var intVal = int.parse(segment); + intVal++; + newPreRelease[i] = intVal.toString(); + found = true; + break; + } + } + if (!found) { + newPreRelease.add('1'); + } + + return Version(major, minor, patch, preRelease: newPreRelease); + } + + /// Returns a [String] representation of the [Version]. + /// + /// Uses the format "$major.$minor.$patch". + /// If [preRelease] has segments available they are appended as "-segmentOne.segmentTwo", with each segment separated by a period. + /// If [build] is specified, it is appended as "+build.info" where "build.info" is whatever value [build] is set to. + /// If all [preRelease] and [build] are specified, then both are appended, [preRelease] first and [build] second. + /// An example of such output would be "1.0.0-preRelease.segment+build.info". + @override + String toString() { + final StringBuffer output = StringBuffer('$major.$minor.$patch'); + if (_preRelease.isNotEmpty) { + output.write("-${_preRelease.join('.')}"); + } + if (build.trim().isNotEmpty) { + output.write('+${build.trim()}'); + } + return output.toString(); + } + + /// Creates a [Version] instance from a string. + /// + /// The string must conform to the specification at http://semver.org/ + /// Throws [FormatException] if the string is empty or does not conform to the spec. + static Version parse(String versionString) { + if (versionString.trim().isEmpty) { + throw const FormatException('Cannot parse empty string into version'); + } + if (!_versionRegex.hasMatch(versionString)) { + throw const FormatException('Not a properly formatted version string'); + } + final Match m = _versionRegex.firstMatch(versionString)!; + final String version = m.group(1)!; + + int? major, minor, patch; + final List parts = version.split('.'); + major = int.parse(parts[0]); + if (parts.length > 1) { + minor = int.parse(parts[1]); + if (parts.length > 2) { + patch = int.parse(parts[2]); + } + } + + final String preReleaseString = m.group(3) ?? ''; + List preReleaseList = []; + if (preReleaseString.trim().isNotEmpty) { + preReleaseList = preReleaseString.split('.'); + } + final String build = m.group(5) ?? ''; + + return Version(major, minor ?? 0, patch ?? 0, build: build, preRelease: preReleaseList); + } + + static int _compare(Version? a, Version? b) { + if (a == null) { + throw ArgumentError.notNull('a'); + } + + if (b == null) { + throw ArgumentError.notNull('b'); + } + + if (a.major > b.major) return 1; + if (a.major < b.major) return -1; + + if (a.minor > b.minor) return 1; + if (a.minor < b.minor) return -1; + + if (a.patch > b.patch) return 1; + if (a.patch < b.patch) return -1; + + if (a.preRelease.isEmpty) { + if (b.preRelease.isEmpty) { + return 0; + } else { + return 1; + } + } else if (b.preRelease.isEmpty) { + return -1; + } else { + int preReleaseMax = a.preRelease.length; + if (b.preRelease.length > a.preRelease.length) { + preReleaseMax = b.preRelease.length; + } + + for (int i = 0; i < preReleaseMax; i++) { + if (b.preRelease.length <= i) { + return 1; + } else if (a.preRelease.length <= i) { + return -1; + } + + if (a.preRelease[i] == b.preRelease[i]) continue; + + final bool aNumeric = _isNumeric(a.preRelease[i]); + final bool bNumeric = _isNumeric(b.preRelease[i]); + + if (aNumeric && bNumeric) { + final double aNumber = double.parse(a.preRelease[i]); + final double bNumber = double.parse(b.preRelease[i]); + if (aNumber > bNumber) { + return 1; + } else { + return -1; + } + } else if (bNumeric) { + return 1; + } else if (aNumeric) { + return -1; + } else { + return a.preRelease[i].compareTo(b.preRelease[i]); + } + } + } + return 0; + } + + /// Creates a [Version] instance from a string. + /// + /// The string must conform to the specification at http://semver.org/ + /// Returns null if the string is empty or does not conform to the spec. + static Version? tryParse(String source) { + try { + return Version.parse(source); + } on FormatException { + return null; + } + } + + static bool _isNumeric(String? s) { + if (s == null) { + return false; + } + return double.tryParse(s) != null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..19e770d --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,210 @@ +name: refreezer +description: ReFreezer + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# 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.7.10 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + + flutter_localizations: + sdk: flutter + + app_links: ^6.0.1 + animations: ^2.0.10 + async: ^2.11.0 + audio_service: ^0.18.14 + audio_session: ^0.1.20 + cached_network_image: ^3.3.0 + clipboard: ^0.1.3 + collection: ^1.18.0 + connectivity_plus: ^6.0.2 + country_currency_pickers: ^3.0.0 + crypto: ^3.0.3 + custom_navigator: + path: ./custom_navigator + device_info_plus: ^10.1.0 + #disk_space_plus: ^0.2.3 + # Gradle 8 updated version: + disk_space_plus: + git: + url: https://github.com/famasf1/disk_space_plus.git + draggable_scrollbar: ^0.1.0 + encrypt: ^5.0.3 + envied: ^0.5.3 + #equalizer_flutter: ^0.0.1 + # Gradle 8 updated version: + equalizer_flutter: + path: ./equalizer_flutter + #external_path: ^1.0.3 + # Gradle 8 updated version: + external_path: + path: ./external_path + filesize: ^2.0.1 + fluttericon: ^2.0.0 + fluttertoast: ^8.2.4 + flutter_cache_manager: ^3.3.1 + flutter_displaymode: ^0.6.0 + #flutter_file_dialog: ^3.0.1 + flutter_inappwebview: ^6.0.0 + flutter_local_notifications: ^17.0.0 + flutter_material_color_picker: ^1.2.0 + flutter_screenutil: ^5.9.0 + get_it: ^7.6.4 + google_fonts: ^6.1.0 + html: ^0.15.3 + http: ^1.2.1 + i18n_extension: ^12.0.1 + intl: ^0.19.0 + json_annotation: ^4.9.0 + just_audio: ^0.9.39 + #just_audio: + # git: + # url: https://github.com/ryanheise/just_audio.git + # ref: visualizer + # path: just_audio + #just_audio_platform_interface: + # git: + # url: https://github.com/ryanheise/just_audio.git + # ref: visualizer + # path: just_audio_platform_interface + logging: ^1.2.0 + #marquee: ^2.2.3 + marquee: + path: ./marquee + #move_to_background: ^1.0.1 + # Gradle 8 updated version: + move_to_background: + path: ./move_to_background + numberpicker: ^2.1.2 + open_filex: ^4.4.0 + package_info_plus: ^8.0.0 + palette_generator: ^0.3.3+3 + path: ^1.8.3 + path_provider: ^2.1.1 + #permission_handler: ^11.1.0 + permission_handler: ^11.3.1 + photo_view: ^0.15.0 + pointycastle: ^3.7.4 + quick_actions: ^1.0.6 + #random_string: ^2.0.1 + #restart_app: ^1.2.1 + # Gradle 8 updated version: + #restart_app: + # git: + # url: https://github.com/Argaros/restart_app.git + rxdart: ^0.27.7 + scrobblenaut: + path: ./scrobblenaut + share_plus: ^9.0.0 + spotify: ^0.13.1 + sqflite: ^2.3.0 + url_launcher: ^6.2.2 + #version: ^3.0.2 Added contents in source directly for compatibility + visibility_detector: ^0.4.0+2 + wakelock_plus: ^1.2.4 + +dependency_overrides: + # TODO: Recheck once flutter_inappwebview version >6.0.0 is released + flutter_inappwebview_android: + git: + url: https://github.com/holzgeist/flutter_inappwebview + path: flutter_inappwebview_android + ref: d89b1d32638b49dfc58c4b7c84153be0c269d057 + +dev_dependencies: + flutter_test: + sdk: flutter + + json_serializable: ^6.7.1 + build_runner: ^2.4.7 + flutter_lints: ^4.0.0 + envied_generator: ^0.5.3 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + assets: + - lib/.env + - assets/cover.jpg + - assets/cover_thumb.jpg + - assets/icon_legacy.png + - assets/icon.png + - assets/favorites_thumb.jpg + - assets/browse_icon.png + + fonts: + # - family: Montserrat + # fonts: + # - asset: assets/fonts/Montserrat-Regular.ttf + # - asset: assets/fonts/Montserrat-Bold.ttf + # weight: 700 + # - asset: assets/fonts/Montserrat-Italic.ttf + # style: italic + - family: MabryPro + fonts: + - asset: assets/fonts/MabryPro.otf + - asset: assets/fonts/MabryProItalic.otf + style: italic + - asset: assets/fonts/MabryProBold.otf + weight: 700 + - asset: assets/fonts/MabryProBlack.otf + weight: 900 + - family: ReFreezerIcons + fonts: + - asset: assets/fonts/ReFreezerIcons.ttf + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/translations/crowdin.py b/translations/crowdin.py new file mode 100644 index 0000000..8a00e1c --- /dev/null +++ b/translations/crowdin.py @@ -0,0 +1,54 @@ +import zipfile +import json + +lang_crowdin = { + "ar": "ar_ar", + "bg": "bul_bg", + "ast": "ast_es", + "de": "de_de", + "el": "el_gr", + "es-ES": "es_es", + "fa": "fa_ir", + "fil": "fil_ph", + "fr": "fr_fr", + "he": "he_il", + "hr": "hr_hr", + "id": "id_id", + "it": "it_id", + "ko": "ko_ko", + "pt-BR": "pt_br", + "ro": "ro_ro", + "ru": "ru_ru", + "tr": "tr_tr", + "pl": "pl_pl", + "uk": "uk_ua", + "hu": "hu_hu", + "ur-PK": "ur_pk", + "hi": "hi_in", + "sk": "sk_sk", + "cs": "cs_cz", + "vi": "vi_vi", + "uwu": "uwu_uwu", + "nl": "nl_NL", + "sl": "sl_SL", + "zh-CN": "zh-CN", +} + + +def generate_dart(): + out = {} + with zipfile.ZipFile("translations.zip") as zip: + for file in zip.namelist(): + if "refreezer.json" in file: + data = zip.open(file).read() + lang = file.split("/")[0] + out[lang_crowdin[lang]] = json.loads(data) + + with open("../lib/languages/crowdin.dart", "w") as f: + data = json.dumps(out, ensure_ascii=False).replace("$", "\\$") + out = f"const crowdin = {data};" + f.write(out) + + +if __name__ == "__main__": + generate_dart() diff --git a/translations/old_languages/ar_ar.dart b/translations/old_languages/ar_ar.dart new file mode 100644 index 0000000..30f3f5e --- /dev/null +++ b/translations/old_languages/ar_ar.dart @@ -0,0 +1,233 @@ +/* + +Translated by: Xandar Null + +*/ + +const language_ar_ar = { + 'ar_ar': { + 'Home': 'القائمة الرئيسية', + 'Search': 'بحث', + 'Library': 'المكتبة', + "Offline mode, can't play flow or smart track lists.": + 'وضع خارج الشبكة, لا تستطيع تشغيل اغاني من قوائم ديزر فلو', + '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': 'أدخل الرمز المميز (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": + 'باستخدام هذا التطبيق ، أنت لا توافق على شروط خدمة ديزر', + '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': + 'أرسال سجلات الاستماع إلى ديزر ، قم بتمكينها لميزات مثل فلو لتعمل بشكل صحيح (ينصح تفعيلها)', + '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.': + 'انسخ ملف الرابط \ الرمز المميز لاستخدامه في تطبيقات أخرى.', + '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 ReFreezer to apply!': + 'تم تغيير اللغة، الرجاء إعادة تشغيل فريزر لتطبيق!', + 'Importing...': 'جار الاستيراد...', + 'Radio': 'راديو', + + //0.5.0 Strings: + 'Storage permission denied!': 'رفض إذن التخزين!', + 'Failed': 'فشل', + 'Queued': 'في قائمة الانتظار', + 'Restart failed downloads': 'أعد استئناف التنزيلات الفاشلة', + 'Clear failed': 'فشل المسح', + 'Download Settings': 'إعدادات التنزيل', + 'Create folder for playlist': 'إنشاء ملف لقائمة التشغيل', + 'Download .LRC lyrics': 'تنزيل ملف كلمات الاغنية .LRC', + 'Proxy': 'بروكسي', + 'Not set': 'غير محدد', + 'Search or paste URL': 'ابحث أو الصق رابط', + 'History': 'تاريخ السماع', + 'Download threads': 'عدد التنزيلات في نفس الوقت', + 'Lyrics unavailable, empty or failed to load!': + 'الكلمات غير متوفرة، فارغة أو فشل تنزيلها!', + 'About': 'حول البرنامج', + 'Telegram Channel': 'قناة التلكرام', + 'To get latest releases': 'لتنزيل اخر اصدارات البرنامج', + 'Official chat': 'الدردشة الرسمية', + 'Telegram Group': 'مجموعة التلكرام', + 'Huge thanks to all the contributors! <3': 'شكرا جزيلا لجميع المساهمين! <3', + 'Edit playlist': 'تعديل قائمة التشغيل', + 'Update': 'تحديث', + 'Playlist updated!': 'تم تحديث قائمة التشغيل!', + 'Downloads added!': 'تم إضافة التنزيلات!', + 'External': 'تخزين', + 'Save cover file for every track': 'حفظ صورة الالبوم لكل اغنية', + 'Download Log': 'سجل التنزيل', + 'Repository': 'Repository', + 'Source code, report issues there.': 'كود المصدر ، ابلغ عن المشاكل هنا.', + + //0.5.2 Strings: + 'Use system theme': 'استخدم ثيم النظام', + 'Light': 'ابيض', + + //0.5.3 Strings: + 'Popularity': 'الشعبية', + 'User': 'المستخدم', + 'Track count': 'عدد الاغاني', + "If you want to use custom directory naming - use '/' as directory separator.": + "إذا كنت تريد استخدام تسمية مخصصة، استخدم '/' كفاصل بين المسار." + } +}; diff --git a/translations/old_languages/de_de.dart b/translations/old_languages/de_de.dart new file mode 100644 index 0000000..60fe206 --- /dev/null +++ b/translations/old_languages/de_de.dart @@ -0,0 +1,239 @@ +/* + +Translated by: Markus + +*/ +const language_de_de = { + 'de_de': { + 'Home': 'Start', + 'Search': 'Suche', + 'Library': 'Mediathek', + "Offline mode, can't play flow or smart track lists.": + 'Offline-Modus, kann keine Flow- oder Smart Track-Listen abspielen.', + 'Added to library': 'Zur Mediathek hinzufügen', + 'Download': 'Download', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Top Titel', + 'Show more tracks': 'Zeige mehr Titel', + 'Top': 'Top', + 'Top Albums': 'Top Alben', + 'Show all albums': 'Zeige alle Alben', + 'Discography': 'Diskografie', + 'Default': 'Standard', + 'Reverse': 'Rückwärts', + 'Alphabetic': 'Alphabetisch', + 'Artist': 'Künstler', + 'Post processing...': 'Nachbearbeitung...', + 'Done': 'Erledigt', + 'Delete': 'Gelöscht', + 'Are you sure you want to delete this download?': + 'Bist du sicher, dass du diesen Download löschen willst?', + 'Cancel': 'Abbrechen', + 'Downloads': 'Downloads', + 'Clear queue': 'Warteschleife löschen', + "This won't delete currently downloading item": + 'Dies löscht das derzeit heruntergeladene Element nicht', + 'Are you sure you want to delete all queued downloads?': + 'Bist du sicher, dass du alle Downloads aus der Warteschleife löschen willst?', + 'Clear downloads history': 'Download-Verlauf löschen', + 'WARNING: This will only clear non-offline (external downloads)': + 'ACHTUNG: (Externe Downloads) werden entfernt', + 'Please check your connection and try again later...': + 'Bitte überprüfe deine Verbindung und versuche es später noch einmal...', + 'Show more': 'Mehr anzeigen', + 'Importer': 'Importieren', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Derzeit begrenzt auf maximal 100 Titel', + 'Due to API limitations': 'Aufgrund von API-Einschränkungen', + 'Enter your playlist link below': + 'Gebe deinen Wiedergabelisten-Link unten ein', + 'Error loading URL!': 'Fehler beim Laden der URL!', + 'Convert': 'Konvertieren', + 'Download only': 'Nur Herunterladen', + 'Downloading is currently stopped, click here to resume.': + 'Das Herunterladen ist derzeit gestoppt, klicke hier, um fortzufahren.', + 'Tracks': 'Titel', + 'Albums': 'Alben', + 'Artists': 'Künstler', + 'Playlists': 'Wiedergabelisten', + 'Import': 'Importieren', + 'Import playlists from Spotify': 'Wiedergabelisten aus Spotify importieren', + 'Statistics': 'Statistiken', + 'Offline tracks': 'Offline-Titel', + 'Offline albums': 'Offline-Alben', + 'Offline playlists': 'Offline-Wiedergabelisten', + 'Offline size': 'Offline-Größe', + 'Free space': 'Freier Speicherplatz', + 'Loved tracks': 'Beliebte Titel', + 'Favorites': 'Favoriten', + 'All offline tracks': 'Alle Offline-Titel', + 'Create new playlist': 'Neue Wiedergabeliste erstellen', + 'Cannot create playlists in offline mode': + 'Wiedergabelisten können im Offline-Modus nicht erstellt werden', + 'Error': 'Fehler', + 'Error logging in! Please check your token and internet connection and try again.': + 'Fehler beim Einloggen! Bitte überprüfe dein Token und deine Internetverbindung und versuche es erneut.', + 'Dismiss': 'Verwerfen', + 'Welcome to': 'Willkommen bei', + 'Please login using your Deezer account.': + 'Bitte melde dich mit deinem Deezer-Konto an.', + 'Login using browser': 'Anmeldung über Browser', + 'Login using token': 'Anmeldung per Token', + 'Enter ARL': 'ARL eingeben', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Speichern', + "If you don't have account, you can register on deezer.com for free.": + 'Wenn Du noch kein Konto hast, kannst Du Dich kostenlos auf deezer.com registrieren.', + 'Open in browser': 'Im Browser öffnen', + "By using this app, you don't agree with the Deezer ToS": + 'Wenn Du diese Anwendung verwendest, bist Du nicht mit den Deezer ToS einverstanden', + 'Play next': 'Als nächstes spielen', + 'Add to queue': 'Zur Warteschleife hinzufügen', + 'Add track to favorites': 'Titel zu Favoriten hinzufügen', + 'Add to playlist': 'Zur Wiedergabeliste hinzufügen', + 'Select playlist': 'Wiedergabeliste auswählen', + 'Track added to': 'Titel hinzugefügt zu', + 'Remove from playlist': 'Aus Wiedergabeliste entfernen', + 'Track removed from': 'Titel entfernt aus', + 'Remove favorite': 'Favorit entfernen', + 'Track removed from library': 'Titel aus Mediathek entfernt', + 'Go to': 'Gehe zu', + 'Make offline': 'Offline verfügbar machen', + 'Add to library': 'Zur Mediathek hinzufügen', + 'Remove album': 'Album entfernen', + 'Album removed': 'Album entfernt', + 'Remove from favorites': 'Aus Favoriten entfernen', + 'Artist removed from library': 'Künstler aus Bibliothek entfernt', + 'Add to favorites': 'Zu Favoriten hinzufügen', + 'Remove from library': 'Aus der Mediathek entfernen', + 'Add playlist to library': 'Wiedergabeliste zur Mediathek hinzufügen', + 'Added playlist to library': 'Wiedergabeliste zur Mediathek hinzugefügt', + 'Make playlist offline': 'Wiedergabeliste offline verfügbar machen', + 'Download playlist': 'Wiedergabeliste herunterladen', + 'Create playlist': 'Wiedergabeliste erstellen', + 'Title': 'Titel', + 'Description': 'Beschreibung', + 'Private': 'Privat', + 'Collaborative': 'Collaborative', + 'Create': 'Erstellen', + 'Playlist created!': 'Wiedergabeliste erstellt!', + 'Playing from:': 'Wiedergabe von:', + 'Queue': 'Warteschleife', + 'Offline search': 'Offline-Suche', + 'Search Results': 'Suchergebnisse', + 'No results!': 'Keine Ergebnisse!', + 'Show all tracks': 'Alle Titel anzeigen', + 'Show all playlists': 'Alle Wiedergabelisten anzeigen', + 'Settings': 'Einstellungen', + 'General': 'Allgemein', + 'Appearance': 'Aussehen', + 'Quality': 'Qualität', + 'Deezer': 'Deezer', + 'Theme': 'App-Design', + 'Currently': 'Aktuell', + 'Select theme': 'App-Design auswählen', + 'Light (default)': 'Heller Modus (Standard)', + 'Dark': 'Dunkler Modus', + 'Black (AMOLED)': 'Schwarz (AMOLED)', + 'Deezer (Dark)': 'Deezer (Dunkel)', + 'Primary color': 'Primärfarbe', + 'Selected color': 'Ausgewählte Farbe', + 'Use album art primary color': 'Verwende die Primärfarbe des Albumcovers', + 'Warning: might be buggy': 'Warnung: könnte fehlerhaft sein', + 'Mobile streaming': 'Wiedergabe über Mobilfunknetz', + 'Wifi streaming': 'Wiedergabe über WLAN', + 'External downloads': 'Externe Downloads', + 'Content language': 'Content-Sprache', + 'Not app language, used in headers. Now': 'Aktuell', + 'Select language': 'Sprache auswählen', + 'Content country': 'Content-Land', + 'Country used in headers. Now': 'Aktuell', + 'Log tracks': 'Protokolliere Titel', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Gehörte Titel-Protokolle an Deezer senden, damit Flow richtig funktioniert', + 'Offline mode': 'Offline-Modus', + 'Will be overwritten on start.': 'Wird beim Start überschrieben.', + 'Error logging in, check your internet connections.': + 'Fehler beim Anmelden, überprüfe deine Internetverbindung.', + 'Logging in...': 'Angemeldet...', + 'Download path': 'Download-Pfad', + 'Downloads naming': 'Benennung der Downloads', + 'Downloaded tracks filename': 'Dateiname der heruntergeladenen Titel', + 'Valid variables are': 'Gültige Variablen sind', + 'Reset': 'Zurücksetzen', + 'Clear': 'Löschen', + 'Create folders for artist': 'Ordner für Künstler erstellen', + 'Create folders for albums': 'Ordner für Alben erstellen', + 'Separate albums by discs': 'Alben nach Discs trennen', + 'Overwrite already downloaded files': + 'Bereits heruntergeladene Dateien überschreiben', + 'Copy ARL': 'ARL kopieren', + 'Copy userToken/ARL Cookie for use in other apps.': + 'UserToken / ARL-Cookie zur Verwendung in anderen Anwendungen kopieren.', + 'Copied': 'Kopiert', + 'Log out': 'Abmelden', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Aufgrund von Plugin-Inkompatibilität ist die Anmeldung mit dem Browser ohne Neustart nicht möglich.', + '(ARL ONLY) Continue': '(NUR ARL) Fortfahren', + 'Log out & Exit': 'Abmelden & Beenden', + 'Pick-a-Path': 'Wähle einen Pfad', + 'Select storage': 'Verzeichnis auswählen', + 'Go up': 'Nach oben', + 'Permission denied': 'Zugriff verweigert', + 'Language': 'Sprache', + 'Language changed, please restart ReFreezer to apply!': + 'Sprache geändert, bitte ReFreezer neu starten!', + 'Importing...': 'Importiere...', + 'Radio': 'Radio', + //0.5.0 Strings: + 'Storage permission denied!': 'Speicherzugriff verweigert!', + 'Failed': 'Fehlgeschlagen', + 'Queued': 'Warteschleife', + //Updated in 0.5.1 - used in context of download: + 'External': 'Speicherplatz', + //0.5.0 + 'Restart failed downloads': 'Fehlgeschlagene Downloads neu starten', + 'Clear failed': 'Fehlgeschlagene Downloads löschen', + 'Download Settings': 'Download-Einstellungen', + 'Create folder for playlist': 'Ordner für Wiedergabelisten erstellen', + 'Download .LRC lyrics': 'Download .LRC lyrics', + 'Proxy': 'Proxy', + 'Not set': 'Nicht festgelegt', + 'Search or paste URL': 'Suchen oder Einfügen von URLs', + 'History': 'Verlauf', + //Updated 0.5.1 + 'Download threads': 'Gleichzeitige Downloads', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': + 'Lyrics nicht verfügbar, leer oder laden fehlgeschlagen!', + 'About': 'Über', + 'Telegram Channel': 'Telegram Kanal', + 'To get latest releases': 'Um die neuesten Versionen zu erhalten', + 'Official chat': 'Offizieller Chat', + 'Telegram Group': 'Telegram Gruppe', + 'Huge thanks to all the contributors! <3': + 'Großer Dank an alle Mitwirkenden! <3', + 'Edit playlist': 'Wiedergabeliste bearbeiten', + 'Update': 'Update', + 'Playlist updated!': 'Wiedergabeliste aktualisiert!', + 'Downloads added!': 'Downloads hinzugefügt!', + + //0.5.1 Strings: + 'Save cover file for every track': 'Albumcover für jeden Titel speichern', + 'Download Log': 'Download-Log', + 'Repository': 'Repository', + 'Source code, report issues there.': 'Quellcode, Probleme dort melden.', + + //0.5.2 Strings: + 'Use system theme': 'Systemvorgabe benutzen', + 'Light': 'Heller Modus', + + //0.5.3 Strings: + 'Popularity': 'Beliebtheit', + 'User': 'Benutzer', + 'Track count': 'Anzahl der Titel', + "If you want to use custom directory naming - use '/' as directory separator.": + "Wenn du eine benutzerdefinierte Verzeichnisbenennung verwenden möchtest - verwende '/' als Verzeichnistrennzeichen." + } +}; diff --git a/translations/old_languages/el_gr.dart b/translations/old_languages/el_gr.dart new file mode 100644 index 0000000..6fb198b --- /dev/null +++ b/translations/old_languages/el_gr.dart @@ -0,0 +1,244 @@ +/* + +Translated by: VIRGIN_KLM + + */ + +const language_el_gr = { + 'el_gr': { + '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': 'Κορυφαία Album', + 'Show all albums': 'Εμφάνιση όλων των album', + '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': 'Album', + 'Artists': 'Καλλιτέχνες', + 'Playlists': 'Λίστες αναπαραγωγής', + 'Import': 'Εισαγωγή', + 'Import playlists from Spotify': + 'Εισαγωγή λιστών αναπαραγωγής από το Spotify', + 'Statistics': 'Στατιστικά', + 'Offline tracks': 'Κομμάτια εκτός σύνδεσης', + 'Offline albums': 'Album εκτός σύνδεσης', + '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.': + 'Σφάλμα σύνδεσης! Ελέγξτε το token και τη σύνδεσή σας στο δίκτυο και δοκιμάστε ξανά.', + 'Dismiss': 'Απόρριψη', + 'Welcome to': 'Καλωσήρθατε στο', + 'Please login using your Deezer account.': + 'Συνδεθείτε χρησιμοποιώντας τον λογαριασμό σας στο 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.', + 'Open in browser': 'Ανοιγμα σε πρόγραμμα περιήγησης', + "By using this app, you don't agree with the Deezer ToS": + 'Χρησιμοποιώντας αυτήν την εφαρμογή, δεν συμφωνείτε με τους κανονισμούς χρήσης Deezer', + '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', + 'Album removed': 'Το album καταργήθηκε', + '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': + 'Χρησιμοποιήστε το πρωτεύον χρώμα του εξώφυλλου του album', + 'Warning: might be buggy': 'Προειδοποίηση: μπορεί να μη λειτουργεί σωστά', + 'Mobile streaming': 'Ροή μέσω δεδομένων κινητού δικτύου', + 'Wifi streaming': 'Ροή μέσω WIFI', + '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': 'Δημιουργήστε φακέλων για album', + 'Separate albums by discs': 'Διαχωρισμός albums σε δίσκους', + 'Overwrite already downloaded files': 'Αντικατάσταση ήδη ληφθέντων αρχείων', + 'Copy ARL': 'Αντιγραφή ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Αντιγραφή 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': 'Διαλέξτε ένα μονοπάτι', + 'Select storage': 'Επιλέξτε χώρο αποθήκευσης', + 'Go up': 'Πήγαινε πάνω', + 'Permission denied': 'Η άδεια απορρίφθηκε', + 'Language': 'Γλώσσα', + 'Language changed, please restart ReFreezer to apply!': + 'Η γλώσσα άλλαξε, κάντε επανεκκίνηση του ReFreezer για εφαρμογή!', + 'Importing...': 'Εισαγωγή...', + 'Radio': 'Ραδιόφωνο', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + 'Το κομμάτι δεν είναι διαθέσιμο στο Deezer!', + 'Failed to download track! Please restart.': + 'Αποτυχία λήψης κομματιού! Κάντε επανεκκίνηση. ', + + //0.5.0 Strings: + 'Storage permission denied!': 'Η άδεια χώρου αποθήκευσης απορρίφθηκε!', + 'Failed': 'Απέτυχαν', + 'Queued': 'Σε ουρά', + //Updated in 0.5.1 - used in context of download: + 'External': 'Χώρος αποθήκευσης', + //0.5.0 + 'Restart failed downloads': 'Επανεκκίνηση αποτυχημένων λήψεων', + 'Clear failed': 'Εκκαθάριση αποτυχημένων', + 'Download Settings': 'Ρυθμίσεις Λήψεων', + 'Create folder for playlist': 'Δημιουργία φακέλου για λίστα αναπαραγωγής', + 'Download .LRC lyrics': 'Λήψη στίχων .LRC', + 'Proxy': 'Μεσολαβητής', + 'Not set': 'Δεν ρυθμίστηκε', + 'Search or paste URL': 'Αναζήτηση ή επικόλληση διεύθυνσης URL', + 'History': 'Ιστορικό', + //Updated 0.5.1 + 'Download threads': 'Ταυτόχρονες λήψεις', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': + 'Οι στίχοι δεν είναι διαθέσιμοι, είναι άδειοι ή δεν φορτώθηκαν!', + 'About': 'Σχετικά', + 'Telegram Channel': 'Κανάλι Telegram ', + 'To get latest releases': 'Για να λάβετε τις τελευταίες κυκλοφορίες', + 'Official chat': 'Επίσημη συνομιλία', + 'Telegram Group': 'Ομάδα Telegram', + 'Huge thanks to all the contributors! <3': + 'Πολλά ευχαριστώ σε όλους τους συνεισφέροντες! <3', + 'Edit playlist': 'Edit playlist', + 'Update': 'Ενημέρωση', + 'Playlist updated!': 'Η λίστα αναπαραγωγής ενημερώθηκε!', + 'Downloads added!': 'Προστέθηκαν λήψεις!', + + //0.5.1 Strings: + 'Save cover file for every track': 'Αποθήκευση εξώφυλλου για κάθε κομμάτι', + 'Download Log': 'Αρχείο καταγραφής λήψεων', + 'Repository': 'Repository', + 'Source code, report issues there.': + 'Πηγαίος κώδικας, αναφέρετε ζητήματα εκεί.', + + //0.5.2 Strings: + 'Use system theme': 'Χρησιμοποίηση θέματος συστήματος', + 'Light': 'Φωτεινο' + } +}; diff --git a/translations/old_languages/es_es.dart b/translations/old_languages/es_es.dart new file mode 100644 index 0000000..2db6294 --- /dev/null +++ b/translations/old_languages/es_es.dart @@ -0,0 +1,235 @@ +/* + +Translated by: ArcherDelta & PetFix + +*/ + +const language_es_es = { + 'es_es': { + 'Home': 'Inicio', + 'Search': 'Buscar', + 'Library': 'Biblioteca', + "Offline mode, can't play flow or smart track lists.": + 'Modo sin conexión, no se puede reproducir el flow o las listas de pistas inteligentes.', + 'Added to library': 'Agregado a la biblioteca', + 'Download': 'Descargar', + 'Disk': 'Disco', + 'Offline': 'Sin conexión', + 'Top Tracks': 'Los mejores temas', + 'Show more tracks': 'Mostrar más pistas', + 'Top': 'Top', + 'Top Albums': 'Mejores álbumes', + 'Show all albums': 'Mostrar todos los álbumes', + 'Discography': 'Discografía', + 'Default': 'Predeterminado', + 'Reverse': 'Invertir', + 'Alphabetic': 'Alfabético', + 'Artist': 'Artista', + 'Post processing...': 'Post procesamiento...', + 'Done': 'Hecho', + 'Delete': 'Eliminar', + 'Are you sure you want to delete this download?': + '¿Estás seguro de que quieres borrar esta descarga?', + 'Cancel': 'Cancelar', + 'Downloads': 'Descargas', + 'Clear queue': 'Limpiar la cola', + "This won't delete currently downloading item": + 'Esto no borrará el elemento que se está descargando actualmente', + 'Are you sure you want to delete all queued downloads?': + '¿Estás seguro de que quieres borrar todas las descargas en cola?', + 'Clear downloads history': 'Borrar el historial de descargas', + 'WARNING: This will only clear non-offline (external downloads)': + 'ADVERTENCIA: Esto sólo borrará las descargas que no están en modo sin conexión (descargas externas).', + 'Please check your connection and try again later...': + 'Por favor, compruebe su conexión y vuelva a intentarlo más tarde...', + 'Show more': 'Mostrar más', + 'Importer': 'Importador', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Actualmente sólo se soporta Spotify, con un límite de 100 pistas', + 'Due to API limitations': 'Debido a limitaciones de API', + 'Enter your playlist link below': + 'Ingrese el enlace de su lista de reproducción a continuación', + 'Error loading URL!': '¡Error al cargar la URL!', + 'Convert': 'Convertir', + 'Download only': 'Sólo descargar', + 'Downloading is currently stopped, click here to resume.': + 'La descarga está actualmente detenida, haga clic aquí para reanudarla.', + 'Tracks': 'Pistas', + 'Albums': 'Álbumes', + 'Artists': 'Artistas', + 'Playlists': 'Listas de reproducción', + 'Import': 'Importar', + 'Import playlists from Spotify': + 'Importar listas de reproducción de Spotify', + 'Statistics': 'Estadísticas', + 'Offline tracks': 'Pistas sin conexión', + 'Offline albums': 'Álbumes sin conexión', + 'Offline playlists': 'Listas de reproducción sin conexión', + 'Offline size': 'El tamaño sin conexión', + 'Free space': 'Espacio libre', + 'Loved tracks': 'Pistas favoritas', + 'Favorites': 'Favoritas', + 'All offline tracks': 'Todas las pistas fuera de línea', + 'Create new playlist': 'Crear nueva lista de reproducción', + 'Cannot create playlists in offline mode': + 'No se pueden crear listas de reproducción en el modo sin conexión', + 'Error': 'Error', + 'Error logging in! Please check your token and internet connection and try again.': + '¡Error al iniciar la sesión! Por favor, compruebe su token y su conexión a Internet e inténtelo de nuevo.', + 'Dismiss': 'Descartar', + 'Welcome to': 'Bienvenido a', + 'Please login using your Deezer account.': + 'Por favor, inicie sesión con su cuenta de Deezer.', + 'Login using browser': 'Ingresar usando el navegador', + 'Login using token': 'Ingresar usando token', + 'Enter ARL': 'Ingrese ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Guardar', + "If you don't have account, you can register on deezer.com for free.": + 'Si no tienes una cuenta, puedes registrarte en deezer.com de forma gratuita.', + 'Open in browser': 'Abrir en el navegador', + "By using this app, you don't agree with the Deezer ToS": + 'Al usar esta aplicación, no está de acuerdo con las Condiciones de servicio de Deezer', + 'Play next': 'Reproducir siguiente', + 'Add to queue': 'Añadir a la cola', + 'Add track to favorites': 'Agregar pista a favoritos', + 'Add to playlist': 'Agregar a la lista de reproducción', + 'Select playlist': 'Seleccionar lista de reproducción', + 'Track added to': 'Pista agregada a', + 'Remove from playlist': 'Quitar de la lista de reproducción', + 'Track removed from': 'Pista eliminada de', + 'Remove favorite': 'Eliminar favorito', + 'Track removed from library': 'Pista eliminada de la biblioteca', + 'Go to': 'Ir a', + 'Make offline': 'Hacerlo sin conexión', + 'Add to library': 'Agregar a la biblioteca', + 'Remove album': 'Eliminar álbum', + 'Album removed': 'Álbum eliminado', + 'Remove from favorites': 'Eliminar de favoritos', + 'Artist removed from library': 'Artista eliminado de la biblioteca', + 'Add to favorites': 'Agregar a favoritos', + 'Remove from library': 'Eliminar de la biblioteca', + 'Add playlist to library': 'Agregar lista de reproducción a la biblioteca', + 'Added playlist to library': + 'Lista de reproducción agregada a la biblioteca', + 'Make playlist offline': 'Hacer lista de reproducción sin conexión', + 'Download playlist': 'Descargar lista de reproducción', + 'Create playlist': 'Crear lista de reproducción', + 'Title': 'Título', + 'Description': 'Descripción', + 'Private': 'Privado', + 'Collaborative': 'Colaborativo', + 'Create': 'Crear', + 'Playlist created!': 'Lista de reproducción creada!', + 'Playing from:': 'Reproduciendo desde:', + 'Queue': 'Cola', + 'Offline search': 'Búsqueda sin conexión', + 'Search Results': 'Resultados de la búsqueda', + 'No results!': 'No hay resultados!', + 'Show all tracks': 'Mostrar todas las pistas', + 'Show all playlists': 'Mostrar todas las listas de reproducción', + 'Settings': 'Ajustes', + 'General': 'General', + 'Appearance': 'Apariencia', + 'Quality': 'Calidad', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Actualmente', + 'Select theme': 'Seleccione el tema', + 'Light (default)': 'Claro (predeterminado)', + 'Dark': 'Oscuro', + 'Black (AMOLED)': 'Negro (AMOLED)', + 'Deezer (Dark)': 'Deezer (oscuro)', + 'Primary color': 'Color primario', + 'Selected color': 'Color seleccionado', + 'Use album art primary color': + 'Usar el color primario de la carátula del álbum', + 'Warning: might be buggy': 'Advertencia: podría tener errores', + 'Mobile streaming': 'Transmisión móvil', + 'Wifi streaming': 'Transmisión WiFi', + 'External downloads': 'Descargas externas', + 'Content language': 'Lenguaje del contenido', + 'Not app language, used in headers. Now': + 'No es un lenguaje de la aplicación, se usa en los encabezados. Ahora', + 'Select language': 'Seleccione el idioma', + 'Content country': 'País del contenido', + 'Country used in headers. Now': 'País utilizado en los encabezados. Ahora', + 'Log tracks': 'Seguimiento de las pistas', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Envía los registros de escucha de las pistas a Deezer, habilítalo para que funciones como Flow funcionen correctamente', + 'Offline mode': 'Modo sin conexión', + 'Will be overwritten on start.': 'Se sobrescribirá al inicio.', + 'Error logging in, check your internet connections.': + 'Error al iniciar sesión, verifique su conexión a internet.', + 'Logging in...': 'Ingresando...', + 'Download path': 'Ruta de las descargas', + 'Downloads naming': 'Nombramiento de las descargas', + 'Downloaded tracks filename': 'Nombre de archivo de las pistas descargadas', + 'Valid variables are': 'Las variables válidas son', + 'Reset': 'Reiniciar', + 'Clear': 'Limpiar', + 'Create folders for artist': 'Crear carpetas por artista', + 'Create folders for albums': 'Crear carpetas por álbumes', + 'Separate albums by discs': 'Separar los álbumes por discos', + 'Overwrite already downloaded files': + 'Sobrescribir los archivos ya descargados', + 'Copy ARL': 'Copiar ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copia el Token de usuario/Cookie ARL para su uso en otras aplicaciones.', + 'Copied': 'Copiado', + 'Log out': 'Cerrar sesión', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Debido a la incompatibilidad de los plugins, no se puede iniciar la sesión con el navegador sin reiniciar.', + '(ARL ONLY) Continue': 'Continuar (SÓLO ARL)', + 'Log out & Exit': 'Cerrar sesión y salir', + 'Pick-a-Path': 'Escoja una ruta', + 'Select storage': 'Seleccionar el almacenamiento', + 'Go up': 'Subir', + 'Permission denied': 'Permiso denegado', + 'Language': 'Idioma', + 'Language changed, please restart ReFreezer to apply!': + '¡El idioma ha cambiado, por favor reinicie ReFreezer para aplicarlo!', + 'Importing...': 'Importando...', + 'Radio': 'Radio', + 'Flow': 'Flow', + + //0.5.0 Strings: + 'Storage permission denied!': 'Permiso de almacenamiento denegado!', + 'Failed': 'Fallido', + 'Queued': 'Puesto en cola', + 'External': 'Almacenamiento', + 'Restart failed downloads': 'Reiniciar descargas fallidas', + 'Clear failed': 'Limpiar fallidas', + 'Download Settings': 'Opciones de descarga', + 'Create folder for playlist': 'Crear carpeta para lista de reproducción', + 'Download .LRC lyrics': 'Descargar archivo .LRC', + 'Proxy': 'Proxy', + 'Not set': 'No establecido', + 'Search or paste URL': 'Buscar o pegar URL', + 'History': 'Historial', + 'Download threads': 'Descargas simultáneas', + 'Lyrics unavailable, empty or failed to load!': + 'Letras no disponibles, vacías o no se pudieron cargar!', + 'About': 'Acerca de', + 'Telegram Channel': 'Canal de Telegram', + 'To get latest releases': 'Para obtener los últimos lanzamientos', + 'Official chat': 'Chat oficial', + 'Telegram Group': 'Grupo de Telegram', + 'Huge thanks to all the contributors! <3': + 'Muchas gracias a todos los contribuyentes contributors! <3', + 'Edit playlist': 'Editar lista de reproducción', + 'Update': 'Actualizar', + 'Playlist updated!': 'Lista de reproducción actualizada!', + 'Downloads added!': 'Descargas agregadas!', + 'Save cover file for every track': + 'Guarde el archivo de portada para cada pista', + 'Download Log': 'Registro de Descarga', + 'Repository': 'Repositorio', + 'Source code, report issues there.': + 'Código fuente, informe de problemas allí.', + + //0.5.2 Strings: + 'Use system theme': 'Usar tema del sistema', + 'Light': 'blanco' + } +}; diff --git a/translations/old_languages/fil_ph.dart b/translations/old_languages/fil_ph.dart new file mode 100644 index 0000000..f45d958 --- /dev/null +++ b/translations/old_languages/fil_ph.dart @@ -0,0 +1,194 @@ +/* + +Translated by: Chino Pacia +Revised by: Garri Palao + +*/ + +const language_fil_ph = { + 'fil_ph': { + 'Home': 'Home', + 'Search': 'Maghanap', + 'Library': 'Library', + "Offline mode, can't play flow or smart track lists.": + 'Ikaw ay naka-offline mode, hindi ka pwedeng mag-play ng flow o mga smart track.', + 'Added to library': 'Idinagdag na sa library', + 'Download': 'I-download', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Mga Nangungunang Track', + 'Show more tracks': 'Ipakita ang iba pang mga track', + 'Top': 'Nangunguna', + 'Top Albums': 'Nangungunang mga Album', + 'Show all albums': 'Ipakita lahat ng album', + 'Discography': 'Discography', + 'Default': 'Default', + 'Reverse': 'Pabalik', + 'Alphabetic': 'Alphabetic', + 'Artist': 'Artist', + 'Post processing...': 'Tinatapos na ang proseso...', + 'Done': 'Tapos na', + 'Delete': 'Burahin', + 'Are you sure you want to delete this download?': + 'Sigurado ka bang buburahin mo ang iyong dinownload?', + 'Cancel': 'I-kansel', + 'Downloads': 'Mga Download', + 'Clear queue': 'I-clear ang queue', + "This won't delete currently downloading item": + 'Hindi nito buburahin ang dina-download mo ngayon', + 'Are you sure you want to delete all queued downloads?': + 'Sigurado ka bang buburahin lahat ang mga dina-download?', + 'Clear downloads history': 'I-clear ang kasaysayan ng mga download', + 'WARNING: This will only clear non-offline (external downloads)': + 'BABALA: Buburahin lang nito ang hindi pang-offline (mga eksternal na download)', + 'Please check your connection and try again later...': + 'I-check ang iyong koneksiyon at maaaring subukan mo ulit mamaya...', + 'Show more': 'Higit pa', + 'Importer': 'Taga-import', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Suportado lang ang Spotify sa ngayon,na may limit sa 100 mga track', + 'Due to API limitations': 'Dahil sa limitasyon ng API', + 'Enter your playlist link below': + 'Pakilagay ang link ng iyong playlist sa ibaba', + 'Error loading URL!': 'Nagkaroon ng problema sa URL!', + 'Convert': 'I-convert', + 'Download only': 'I-download lang', + 'Downloading is currently stopped, click here to resume.': + 'Huminto ang download mo, mag-click dito para ituloy', + 'Tracks': 'Mga Track', + 'Albums': 'Mga Album', + 'Artists': 'Mga Artist', + 'Playlists': 'Mga Playlist', + 'Import': 'I-import', + 'Import playlists from Spotify': + 'I-import ang mga playlist galing sa Spotify', + 'Statistics': 'Statistics', + 'Offline tracks': 'Mga offline na track', + 'Offline albums': 'Mga offline na album', + 'Offline playlists': 'Mga offline na playlist', + 'Offline size': 'Laki ng offline', + 'Free space': 'Natitirang space', + 'Loved tracks': 'Pinusuang mga track', + 'Favorites': 'Mga paborito', + 'All offline tracks': 'Lahat ng track na pang-offline', + 'Create new playlist': 'Gumawa ng bagong playlist', + 'Cannot create playlists in offline mode': + 'Hindi makagagawa ng playlist habang naka-offline mode', + 'Error': 'Error', + 'Error logging in! Please check your token and internet connection and try again.': + 'Hindi maka-login! I-check ang iyong token at koneksiyon at ulitin mo.', + 'Dismiss': 'I-dismiss', + 'Welcome to': 'Welcome sa', + 'Please login using your Deezer account.': + 'Paki-login ang iyong Deezer account', + 'Login using browser': 'Mag-login gamit ng browser', + 'Login using token': 'Mag-login gamit ng token', + 'Enter ARL': 'Pakilagay ang ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'I-save', + "If you don't have account, you can register on deezer.com for free.": + 'Kung wala kang account, pumunta sa deezer.com para sa libreng pag-register.', + 'Open in browser': 'Buksan sa browser', + "By using this app, you don't agree with the Deezer ToS": + 'Sa pag-gamit nitong app, ikaw ay hindi sumusunod sa Deezer ToS', + 'Play next': 'I-play ang kasunod', + 'Add to queue': 'Idagdag sa queue', + 'Add track to favorites': 'Idagdag ang track sa mga paborito', + 'Add to playlist': 'Idagdag sa playlist', + 'Select playlist': 'Piliin ang playlist', + 'Track added to': 'Idinagdag ang track sa', + 'Remove from playlist': 'Tinanggal sa playlist', + 'Track removed from': 'Tinanggal ang track sa', + 'Remove favorite': 'Tanggalin ang paborito', + 'Track removed from library': 'Tinanggal ang track sa library', + 'Go to': 'Pumunta sa', + 'Make offline': 'Gawing offline', + 'Add to library': 'Idagdag sa library', + 'Remove album': 'Tanggalin ang album', + 'Album removed': 'Tinanggal ang album', + 'Remove from favorites': 'Tanggalin sa mga paborito', + 'Artist removed from library': 'Tinanggal ang artist sa library', + 'Add to favorites': 'Idagdag sa mga paborito', + 'Remove from library': 'Tanggalin sa library', + 'Add playlist to library': 'Idagdag ang playlist sa library', + 'Added playlist to library': 'Idinagdag ang playlist sa library', + 'Make playlist offline': 'Gawing offline ang playlist', + 'Download playlist': 'I-download ang playlist', + 'Create playlist': 'Gumawa ng playlist', + 'Title': 'Pamagat', + 'Description': 'Deskripsiyon', + 'Private': 'Pribado', + 'Collaborative': 'Pagtutulungan', + 'Create': 'Mag-buo', + 'Playlist created!': 'Nagawa na ang playlist!', + 'Playing from:': 'Tumutugtog galing sa:', + 'Queue': 'Queue', + 'Offline search': 'Offline na paghahanap', + 'Search Results': 'Resulta sa Paghahanap', + 'No results!': 'Walang mahanap!', + 'Show all tracks': 'Ipakita lahat ng mga track', + 'Show all playlists': 'Ipakita lahat ng mga playlist', + 'Settings': 'Mga Setting', + 'General': 'Pangkalahatan', + 'Appearance': 'Itsura', + 'Quality': 'Kalidad', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Kasalukuyan', + 'Select theme': 'Piliin ang Tema', + 'Light (default)': 'Puti (Default)', + 'Dark': 'Dark', + 'Black (AMOLED)': 'Maitim (AMOLED)', + 'Deezer (Dark)': 'Deezer (Madilim)', + 'Primary color': 'Pangunahing kulay', + 'Selected color': 'Piniling kulay', + 'Use album art primary color': 'Gamitin ang pangunahing kulay ng album art', + 'Warning: might be buggy': 'Babala: Pwedeng magkaroon ng bug', + 'Mobile streaming': 'Pag-stream sa mobile', + 'Wifi streaming': 'Pag-stream sa Wifi', + 'External downloads': 'Eksternal na download', + 'Content language': 'Wika ng nilalaman', + 'Not app language, used in headers. Now': + 'gagamitin lang ang wika sa header, hindi sa app. Ngayon', + 'Select language': 'Piliin ang wika', + 'Content country': 'Bansa ng nilalaman', + 'Country used in headers. Now': 'Gagamitin ang bansa sa mga header. Ngayon', + 'Log tracks': 'Log ng mga track', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Ipadala ang log ng mga napakinggang track sa Deezer, I-enable mo para gumana nang maayos sa mga feature kagaya ng Flow', + 'Offline mode': 'Offline mode', + 'Will be overwritten on start.': 'Papatungan sa simula pa lang.', + 'Error logging in, check your internet connections.': + 'Hindi maka-login, Pakicheck ang iyong internet connection.', + 'Logging in...': 'Nagla-login...', + 'Download path': 'Paglalagyan ng download', + 'Downloads naming': 'Pagpapangalan sa mga download', + 'Downloaded tracks filename': 'Filename ng mga nadownload na track', + 'Valid variables are': 'Ang mga pwede lang gamitin ay', + 'Reset': 'I-reset', + 'Clear': 'I-clear', + 'Create folders for artist': 'Gumawa ng folder para sa mga artist', + 'Create folders for albums': 'Gumawa ng folder para sa mga album', + 'Separate albums by discs': 'Ihiwalay ang mga album batay sa disk', + 'Overwrite already downloaded files': 'Patungan ang mga nadownload na file', + 'Copy ARL': 'Kopyahin ang ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Kopyahin ang userToken/ARL Cookie para gamitin sa iba pang app.', + 'Copied': 'Nakopya na', + 'Log out': 'Mag-Log out', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Hindi ka makakapag-login gamit ng browser kung hindi mo ito ire-restart dahil hindi pa compatible ang plugin sa ngayon', + '(ARL ONLY) Continue': '(ARL LANG) Ituloy', + 'Log out & Exit': 'Mag-Log out at Lumabas', + 'Pick-a-Path': 'Pumili-ng-Path', + 'Select storage': 'Piliin ang storage', + 'Go up': 'Pumunta paitaas', + 'Permission denied': 'Hindi pinapayagan', + 'Language': 'Wika', + 'Language changed, please restart ReFreezer to apply!': + 'Pinalitan ang wika, paki-restart ang Deezer para mai-apply!', + 'Importing...': 'Ini-import...', + 'Radio': 'Radyo', + 'Flow': 'Flow', + } +}; diff --git a/translations/old_languages/fr_fr.dart b/translations/old_languages/fr_fr.dart new file mode 100644 index 0000000..8632507 --- /dev/null +++ b/translations/old_languages/fr_fr.dart @@ -0,0 +1,251 @@ +/* + +Translated by: Fwwwwwwwwwweze + + */ + +const language_fr_fr = { + 'fr_fr': { + 'Home': 'Accueil', + '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 connexion', + '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': 'Générer des dossiers par artiste', + 'Create folders for albums': 'Générer 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 ReFreezer to apply!': + 'Langue modifiée, veuillez redémarrer ReFreezer pour que les changements prennent effet!', + 'Importing...': 'Importation...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': + "La piste n'est pas disponible sur Deezer!", + 'Failed to download track! Please restart.': + 'Echec du téléchargement de la piste ! Veuillez réessayer.', + + //0.5.0 Strings: + 'Storage permission denied!': "Autorisation d'accès au stockage refusée!", + 'Failed': 'Echec', + 'Queued': "Ajouté à la file d'attente", + //Updated in 0.5.1 - used in context of download: + 'External': 'Storage', + //0.5.0 + 'Restart failed downloads': 'Relancer les téléchargements échoués', + 'Clear failed': 'Effacer les téléchargements échoués', + 'Download Settings': 'Paramètres des téléchargements', + 'Create folder for playlist': 'Générer des dossiers par playlist', + 'Download .LRC lyrics': 'Télécharger les fichiers de paroles .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Non défini', + 'Search or paste URL': 'Rechercher ou coller un lien', + 'History': 'Historique', + //Updated 0.5.1 + 'Download threads': 'Téléchargements simultanés', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': + 'Paroles indisponibles, vides ou erreur de chargement !', + 'About': 'A propos', + 'Telegram Channel': 'Telegram Channel', + 'To get latest releases': "Pour obtenir les dernières versions de l'app", + 'Official chat': 'Chat officiel', + 'Telegram Group': 'Groupe Telegram', + 'Huge thanks to all the contributors! <3': + 'Un grand merci à tous les contributeurs ! <3', + 'Edit playlist': 'Modifier la playlist', + 'Update': 'Mettre à jour', + 'Playlist updated!': 'Playlist mise à jour !', + 'Downloads added!': 'Téléchargements ajoutés !', + + //0.5.1 Strings: + 'Save cover file for every track': + 'Sauvegarder la pochette pour chaque piste', + 'Download Log': 'Journal des téléchargements', + 'Repository': 'Dépôt', + 'Source code, report issues there.': + 'Code source, signaler les problèmes ici.', + + //0.5.2 Strings: + 'Use system theme': 'Utiliser le thème du système', + 'Light': 'Clair', + + //0.5.3 Strings: + 'Popularity': 'Popularité', + 'User': 'Utilisateur', + 'Track count': 'Nombre de pistes', + "If you want to use custom directory naming - use '/' as directory separator.": + "Si vous souhaitez utiliser un nom de répertoire personnalisé, utilisez '/' comme séparateur." + } +}; diff --git a/translations/old_languages/he_il.dart b/translations/old_languages/he_il.dart new file mode 100644 index 0000000..0fbf1eb --- /dev/null +++ b/translations/old_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 ReFreezer to apply!': + 'שפה שונתה, בבקשה הפעל מחדש את ReFreezer כדי להחיל!', + 'Importing...': 'מייבא...', + 'Radio': 'רדיו', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'שיר לא קיים בדיזר!', + 'Failed to download track! Please restart.': 'הורדת השיר נכשלה! התחל מחדש.' + } +}; diff --git a/translations/old_languages/hr_hr.dart b/translations/old_languages/hr_hr.dart new file mode 100644 index 0000000..08119c4 --- /dev/null +++ b/translations/old_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 ReFreezer to apply!': + 'Jezik je promjenjen, molimo vas da ponovno pokrenete ReFreezer 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/translations/old_languages/id_id.dart b/translations/old_languages/id_id.dart new file mode 100644 index 0000000..8374701 --- /dev/null +++ b/translations/old_languages/id_id.dart @@ -0,0 +1,245 @@ +/* + +Translated by: LenteraMalam + + */ + +const language_id_id = { + 'id_id': { + 'Home': 'Beranda', + 'Search': 'Cari', + 'Library': 'Perpustakaan', + "Offline mode, can't play flow or smart track lists.": + 'Mode offline, tidak dapat memutar aliran atau daftar putar pintar.', + 'Added to library': 'Ditambahkan ke Perpustakaan', + 'Download': 'Unduh', + 'Disk': 'Disk', + 'Offline': 'Offline', + 'Top Tracks': 'Lagu Populer', + 'Show more tracks': 'Tampilkan lebih banyak lagu', + 'Top': 'Populer', + 'Top Albums': 'Album Populer', + 'Show all albums': 'Tampilkan semua album', + 'Discography': 'Diskografi', + 'Default': 'Default', + 'Reverse': 'Membalik', + 'Alphabetic': 'Alfabet', + 'Artist': 'Artis', + 'Post processing...': 'Sedang diproses...', + 'Done': 'Selesai', + 'Delete': 'Hapus', + 'Are you sure you want to delete this download?': + 'Apakah kamu yakin ingin menghapus unduhan ini?', + 'Cancel': 'Batalkan', + 'Downloads': 'Unduhan', + 'Clear queue': 'Bersihkan antrean', + "This won't delete currently downloading item": + 'Ini tidak akan menghapus item yang sedang diunduh', + 'Are you sure you want to delete all queued downloads?': + 'Apakah kamu yakin ingin menghapus semua antrean yang terunduh?', + 'Clear downloads history': 'Bersihkan riwayat unduhan', + 'WARNING: This will only clear non-offline (external downloads)': + 'PERINGATAN: Ini hanya akan menghapus non-offline (unduhan eksternal)', + 'Please check your connection and try again later...': + 'Periksa kembali koneksi internet anda dan ulangi kembali...', + 'Show more': 'Tampilkan lebih banyak', + 'Importer': 'Pengimport', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Saat ini hanya mendukung Spotify, dengan batas 100 lagu', + 'Due to API limitations': 'Karena keterbatasan API', + 'Enter your playlist link below': + 'Masukkan link playlist Anda di bawah ini', + 'Error loading URL!': 'Gagal memuat URL!', + 'Convert': 'Konversikan', + 'Download only': 'Hanya mengunduh', + 'Downloading is currently stopped, click here to resume.': + 'Pengunduhan saat ini dihentikan, klik di sini untuk melanjutkan.', + 'Tracks': 'Lagu', + 'Albums': 'Album', + 'Artists': 'Artis', + 'Playlists': 'Daftar Putar', + 'Import': 'Impo', + 'Import playlists from Spotify': 'Impor playlist dari Spotify', + 'Statistics': 'Statistik', + 'Offline tracks': 'Lagu offline', + 'Offline albums': 'Album offline', + 'Offline playlists': 'Daftar putar offline', + 'Offline size': 'Ukuran offline', + 'Free space': 'Penyimpanan tersedia', + 'Loved tracks': 'Lagu yang disukai', + 'Favorites': 'Favorit', + 'All offline tracks': 'Semua lagu offline', + 'Create new playlist': 'Buat daftar putar baru', + 'Cannot create playlists in offline mode': + 'Tidak dapat membuat daftar putar di mode offline', + 'Error': 'Terjadi kesalahan', + 'Error logging in! Please check your token and internet connection and try again.': + 'Kesalahan saat masuk! Periksa token dan koneksi internet Anda, lalu coba lagi.', + 'Dismiss': 'Abaikan', + 'Welcome to': 'Selamat datang di', + 'Please login using your Deezer account.': + 'Silakan masuk menggunakan akun Deezer Anda.', + 'Login using browser': 'Masuk menggunakan browser', + 'Login using token': 'Masuk menggunakan token', + 'Enter ARL': 'Masukkan ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Simpan', + "If you don't have account, you can register on deezer.com for free.": + 'Jika Anda tidak memiliki akun, Anda dapat mendaftar di deezer.com secara gratis.', + 'Open in browser': 'Buka di browser', + "By using this app, you don't agree with the Deezer ToS": + 'Dengan menggunakan aplikasi ini, Anda tidak setuju dengan ToS Deezer', + 'Play next': 'Putar selanjutnya', + 'Add to queue': 'Tambahkan ke antrean', + 'Add track to favorites': 'Tambahkan lagu ke favorit', + 'Add to playlist': 'Tambahkan ke daftar putar', + 'Select playlist': 'Pilih daftar putar', + 'Track added to': 'Lagu ditamhahkan ke', + 'Remove from playlist': 'Hapus dari daftar putar', + 'Track removed from': 'Lagu dihapus dari', + 'Remove favorite': 'Hapus favorit', + 'Track removed from library': 'Lagu dihapus dari perpustakaan', + 'Go to': 'Pergi ke', + 'Make offline': 'Buat offline', + 'Add to library': 'Tambahkan ke perpustakaan', + 'Remove album': 'Hapus album', + 'Album removed': 'Album dihapus', + 'Remove from favorites': 'Hapus dari favorit', + 'Artist removed from library': 'Artis dihapus dari perpustakaan', + 'Add to favorites': 'Tambahkan ke favorit', + 'Remove from library': 'Hapus dari perpustakaan', + 'Add playlist to library': 'Tambahkan daftar putar ke perpustakaan', + 'Added playlist to library': 'Menambahkan daftar putar ke perpustakaan', + 'Make playlist offline': 'Buat daftar putar offline', + 'Download playlist': 'Unduh daftar putar', + 'Create playlist': 'Buat daftar putar', + 'Title': 'Judul', + 'Description': 'Deskripsi', + 'Private': 'Pribadi', + 'Collaborative': 'Kolaboratif', + 'Create': 'Buat', + 'Playlist created!': 'Daftar putar berhasil dibuat!', + 'Playing from:': 'Memainkan:', + 'Queue': 'Antrean', + 'Offline search': 'Pencarian offline', + 'Search Results': 'Hasil perncarian', + 'No results!': 'Hasil tidak ditemukan!', + 'Show all tracks': 'Tampilkan semua lagu', + 'Show all playlists': 'Tampilkan semua daftar putar', + 'Settings': 'Pengaturan', + 'General': 'Umum', + 'Appearance': 'Tampilan', + 'Quality': 'Kualitas', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Saat ini', + 'Select theme': 'Pilih tema', + 'Dark': 'Gelap', + 'Black (AMOLED)': 'Hitam (AMOLED)', + 'Deezer (Dark)': 'Deezer (Gelap)', + 'Primary color': 'Warna utama', + 'Selected color': 'Warna yang dipilih', + 'Use album art primary color': 'Gunakan foto album sebagai warna utama', + 'Warning: might be buggy': 'Peringatan: masih ada bug', + 'Mobile streaming': 'Mobile streaming', + 'Wifi streaming': 'Wifi streaming', + 'External downloads': 'Unduhan eksternal', + 'Content language': 'Bahasa konten', + 'Not app language, used in headers. Now': + 'Bukan bahasa aplikasi, digunakan di header. Digunakan', + 'Select language': 'Pilih bahasa', + 'Content country': 'Wilayah konten', + 'Country used in headers. Now': 'Negara digunakan di header. Digunakan', + 'Log tracks': 'Catatan lagu', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Kirim catatan mendengarkan lagu ke Deezer, aktifkan agar fitur seperti Flow berfungsi dengan benar', + 'Offline mode': 'Mode offline', + 'Will be overwritten on start.': 'Akan ditimpa saat mulai.', + 'Error logging in, check your internet connections.': + 'Kesalahan saat masuk, periksa koneksi internet Anda.', + 'Logging in...': 'Masuk...', + 'Download path': 'Path unduhan', + 'Downloads naming': 'Penamaan unduhan', + 'Downloaded tracks filename': 'Nama file yang diunduh', + 'Valid variables are': 'Variabel yang valid', + 'Reset': 'Atur ulang', + 'Clear': 'Bersihkan', + 'Create folders for artist': 'Buat folder dari artis', + 'Create folders for albums': 'Buat folder dari album', + 'Separate albums by discs': 'Pisahkan album dengan disk', + 'Overwrite already downloaded files': 'Timpa file yang sudah diunduh', + 'Copy ARL': 'Salin ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Salin Token/ARL Cookie untuk digunakan di apps lain.', + 'Copied': 'Tersalin', + 'Log out': 'Keluar', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Karena ketidakcocokan plugin, masuk menggunakan browser tidak tersedia tanpa restart.', + '(ARL ONLY) Continue': '(HANYA ARL) Lanjutkan', + 'Log out & Exit': 'Keluar', + 'Pick-a-Path': 'Pilih-sebuah-Jalur', + 'Select storage': 'Pilih penyimpanan', + 'Go up': 'Naik', + 'Permission denied': 'Akses dilarang', + 'Language': 'Bahasa', + 'Language changed, please restart ReFreezer to apply!': + 'Bahasa diganti, Mulai ulang aplikasi untuk menerapkannya!', + 'Importing...': 'Mengimpor...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Lagu tidak tersedia di Deezer!', + 'Failed to download track! Please restart.': + 'Gagal untuk mengunduh lagu! Ulangi kembali.', + + //0.5.0 Strings: + 'Storage permission denied!': 'Izin penyimpanan ditolak!', + 'Failed': 'Gagal', + 'Queued': 'Dalam antrean', + //Updated in 0.5.1 - used in context of download: + 'External': 'Penyimpanan', + //0.5.0 + 'Restart failed downloads': 'Gagal memulai ulang unduhan', + 'Clear failed': 'Gagal membersihkan', + 'Download Settings': 'Pengaturan unduhan', + 'Create folder for playlist': 'Buat folder dari daftar putar', + 'Download .LRC lyrics': 'Unduh lirik .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Tidak diatur', + 'Search or paste URL': 'Cari atau masukkan URL', + 'History': 'Riwayat', + //Updated 0.5.1 + 'Download threads': 'Unduh bersamaan', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': + 'Lirik tidak tersedia, kosong atau gagal untuk memuat!', + 'About': 'Tentang', + 'Telegram Channel': 'Channel Telegram', + 'To get latest releases': 'Untuk mendapatkan rilisan terbaru', + 'Official chat': 'Obrolan resmi', + 'Telegram Group': 'Grub Telegram', + 'Huge thanks to all the contributors! <3': + 'Terima kasih banyak untuk semua kontributor! <3', + 'Edit playlist': 'Edit daftar putar', + 'Update': 'Perbarui', + 'Playlist updated!': 'Daftar putar diperbarui!', + 'Downloads added!': 'Unduhan ditambahkan!', + + //0.5.1 Strings: + 'Save cover file for every track': 'Simpan cover foto dari setiap lagu', + 'Download Log': 'Catatan unduhan', + 'Repository': 'Repository', + 'Source code, report issues there.': + 'Kode sumber, laporkan masalah disini.', + + //0.5.2 Strings: + 'Use system theme': 'Gunakan tema sistem', + 'Light': 'Cerah', + + //0.5.3 Strings: + 'Popularity': 'Popularitas', + 'User': 'Pengguna', + 'Track count': 'Jumlah lagu', + "If you want to use custom directory naming - use '/' as directory separator.": + "Jika Anda ingin menggunakan penamaan direktori kustom - gunakan '/' sebagai pemisah direktori." + } +}; diff --git a/translations/old_languages/it_it.dart b/translations/old_languages/it_it.dart new file mode 100644 index 0000000..51933c5 --- /dev/null +++ b/translations/old_languages/it_it.dart @@ -0,0 +1,232 @@ +/* + +Translated by: Andrea + +*/ + +const language_it_it = { + 'it_it': { + 'Home': 'Pagina Iniziale', + 'Search': 'Cerca', + 'Library': 'Libreria', + "Offline mode, can't play flow or smart track lists.": + 'Modalità offline, non è possibile riprodurre flow o tracklist smart', + 'Added to library': 'Aggiunto alla libreria', + 'Download': 'Scarica', + 'Disk': 'Disco', + 'Offline': 'Offline', + 'Top Tracks': 'Brani in evidenza', + 'Show more tracks': 'Mostra più brani', + 'Top': 'Top', + 'Top Albums': 'Album in evidenza', + 'Show all albums': 'Mostra tutti gli album', + 'Discography': 'Discografia', + 'Default': 'Default', + 'Reverse': 'Reverse', + 'Alphabetic': 'Alfabetico', + 'Artist': 'Artista', + 'Post processing...': 'Elaborazione...', + 'Done': 'Fatto', + 'Delete': 'Cancellare', + 'Are you sure you want to delete this download?': + 'Sei sicuro di voler cancellare questo download?', + 'Cancel': 'Annulla', + 'Downloads': 'Download', + 'Clear queue': 'Pulisci la coda', + "This won't delete currently downloading item": + 'Questa azione non cancellerà i download', + 'Are you sure you want to delete all queued downloads?': + 'Sei sicuro di voler cancellare tutti i download in coda?', + 'Clear downloads history': 'Pulisci la cronologia dei download', + 'WARNING: This will only clear non-offline (external downloads)': + 'ATTENZIONE: Questa azione, pulirà solo i files che non sono offline (download esterni)', + 'Please check your connection and try again later...': + 'Per favore controlla la tua connessione e riprova più tardi...', + 'Show more': 'Mostra di più', + 'Importer': 'Importa', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Attualmente supporta solo Spotify, con un limite di 100 brani', + 'Due to API limitations': 'A causa delle limitazioni delle API', + 'Enter your playlist link below': + 'Inserisci il link della tua playlist qui sotto', + 'Error loading URL!': "Errore nel caricare l'URL!", + 'Convert': 'Converti', + 'Download only': 'Solo Download', + 'Downloading is currently stopped, click here to resume.': + 'Il download è attualmente interrotto, fare clic qui per riprenderlo.', + 'Tracks': 'Brani', + 'Albums': 'Album', + 'Artists': 'Artisti', + 'Playlists': 'Playlist', + 'Import': 'Importa', + 'Import playlists from Spotify': 'Importa playlists da Spotify', + 'Statistics': 'Statistiche', + 'Offline tracks': 'Brani offline', + 'Offline albums': 'Album offline', + 'Offline playlists': 'Playlist offline', + 'Offline size': 'Spazio occupato offline', + 'Free space': 'Spazio libero', + 'Loved tracks': 'Brani preferiti', + 'Favorites': 'Preferiti', + 'All offline tracks': 'Tutte i brani offline', + 'Create new playlist': 'Crea una nuova playlist', + 'Cannot create playlists in offline mode': + 'Impossibile creare playlist in modalità offline', + 'Error': 'Errore', + 'Error logging in! Please check your token and internet connection and try again.': + "Errore durante l'accesso! Controlla il token, la tua connessione ad internet e riprova.", + 'Dismiss': 'Chiudi', + 'Welcome to': 'Benvenuto su', + 'Please login using your Deezer account.': + 'Per favore, esegui il login utilizzando il tuo account Deezer.', + 'Login using browser': 'Login utilizzando il browser', + 'Login using token': 'Login utilizzando il token', + 'Enter ARL': "Inserisci l'ARL", + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Salva', + "If you don't have account, you can register on deezer.com for free.": + 'Se non possiedi un account, puoi registrarti sul sito deezer.com gratuitamente.', + 'Open in browser': 'Apri nel browser', + "By using this app, you don't agree with the Deezer ToS": + 'Utilizzando questa applicazione, non accetti i ToS di Deezer', + 'Play next': 'Riproduci subito dopo', + 'Add to queue': 'Aggiungi alla coda', + 'Add track to favorites': 'Aggiungi il brano ai preferiti', + 'Add to playlist': 'Aggiungi a playlist...', + 'Select playlist': 'Seleziona playlist', + 'Track added to': 'Brano aggiunto a', + 'Remove from playlist': 'Rimuovi dalla playlist', + 'Track removed from': 'Brano rimosso da', + 'Remove favorite': 'Rimuovi dai preferiti', + 'Track removed from library': 'Brano rimosso dalla libreria', + 'Go to': 'Vai a', + 'Make offline': 'Rendi offline', + 'Add to library': 'Aggiungi alla libreria', + 'Remove album': 'Rimuovi album', + 'Album removed': 'Album rimosso', + 'Remove from favorites': 'Rimuovi dai preferiti', + 'Artist removed from library': 'Artista rimosso dalla libreria', + 'Add to favorites': 'Aggiungi ai preferiti', + 'Remove from library': 'Rimuovi dalla libreria', + 'Add playlist to library': 'Aggiungi playlist alla libreria', + 'Added playlist to library': 'Playlist aggiunta alla libreria', + 'Make playlist offline': 'Rendi la playlist offline', + 'Download playlist': 'Scarica playlist', + 'Create playlist': 'Crea playlist', + 'Title': 'Titolo', + 'Description': 'Descrizione', + 'Private': 'Privata', + 'Collaborative': 'Collaborativa', + 'Create': 'Crea', + 'Playlist created!': 'Playlist creata!', + 'Playing from:': 'Riproduzione da:', + 'Queue': 'Coda', + 'Offline search': 'Ricerca offline', + 'Search Results': 'Risultati della ricerca', + 'No results!': 'Nessun risultato!', + 'Show all tracks': 'Mostra tutti i brani', + 'Show all playlists': 'Mostra tutte le playlists', + 'Settings': 'Opzioni', + 'General': 'Generale', + 'Appearance': 'Aspetto', + 'Quality': 'Qualità', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Attuale', + 'Select theme': 'Seleziona Tema', + 'Light (default)': 'Chiaro (Default)', + 'Dark': 'Scuro', + 'Black (AMOLED)': 'Nero (AMOLED)', + 'Deezer (Dark)': 'Deezer (Scuro)', + 'Primary color': 'Colore Principale', + 'Selected color': 'Colore Selezionato', + 'Use album art primary color': + "Usa il colore principale della copertina dell'album", + 'Warning: might be buggy': 'Attenzione: potrebbe causare problemi', + 'Mobile streaming': 'Streaming con dati', + 'Wifi streaming': 'Streaming con WiFi', + 'External downloads': 'Download esterni', + 'Content language': 'Lingua dei contenuti', + 'Not app language, used in headers. Now': + "Non la lingua dell'app, utilizzata negli header. Adesso", + 'Select language': 'Seleziona la lingua', + 'Content country': 'Contenuto dal Paese', + 'Country used in headers. Now': 'Paese contenuto negli header. Ora', + 'Log tracks': 'Log delle tracce', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Invia i log delle canzioni ascoltate a Deezer, abilitalo affinché features come Flow funzionino correttamente', + 'Offline mode': 'Modalità Offline', + 'Will be overwritten on start.': "Sarà sovrascritto all'avvio.", + 'Error logging in, check your internet connections.': + "Errore durante l'accesso, controlla la tua connessione Internet.", + 'Logging in...': 'Accesso in corso...', + 'Download path': 'Percorso di download', + 'Downloads naming': 'Denominazione dei download', + 'Downloaded tracks filename': 'Nome del file dei brani scaricati', + 'Valid variables are': 'Le variabili valide sono', + 'Reset': 'Ripristina', + 'Clear': 'Pulisci', + 'Create folders for artist': 'Crea cartelle per gli artisti', + 'Create folders for albums': 'Crea cartelle per gli album', + 'Separate albums by discs': 'Separa gli album per disco', + 'Overwrite already downloaded files': 'Sovrascrivi i file già scaricati', + 'Copy ARL': 'Copia ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copia userToken / ARL Cookie da utilizzare in altre app.', + 'Copied': 'Copiato', + 'Log out': 'Disconnettiti', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + "A causa dell'incompatibilità del plug-in, l'accesso tramite browser non è disponibile senza riavvio.", + '(ARL ONLY) Continue': '(SOLO ARL) Continua', + 'Log out & Exit': 'Disconnettiti e Esci', + 'Pick-a-Path': 'Scegli un percorso', + 'Select storage': 'Seleziona dispositivo di archiviazione', + 'Go up': 'Vai su', + 'Permission denied': 'Permesso negato', + 'Language': 'Lingua', + 'Language changed, please restart ReFreezer to apply!': + 'Lingua cambiata, riavvia ReFreezer per applicare la modifica!', + 'Importing...': 'Importando...', + 'Radio': 'Radio', + 'Flow': 'Flow', + + //0.5.0 Strings: + 'Storage permission denied!': 'Autorizzazione di archiviazione negata!', + 'Failed': 'Fallito', + 'Queued': 'In coda', + 'External': 'Archiviazione', + 'Restart failed downloads': 'Riavvia download non riusciti', + 'Clear failed': 'Pulisci fallito', + 'Download Settings': 'Impostazioni download', + 'Create folder for playlist': 'Crea cartella per playlist', + 'Download .LRC lyrics': 'Scarica testi .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Non impostato', + 'Search or paste URL': "Cerca o incolla l'URL", + 'History': 'Storia', + 'Download threads': 'Download simultanei', + 'Lyrics unavailable, empty or failed to load!': + 'Testi non disponibili, vuoti o caricamento non riuscito!', + 'About': 'Info', + 'Telegram Channel': 'Canale Telegram', + 'To get latest releases': 'Per ottenere le ultime versioni', + 'Official chat': 'Chat ufficiale', + 'Telegram Group': 'Gruppo Telegram', + 'Huge thanks to all the contributors! <3': + 'Un enorme grazie a tutti i collaboratori! <3', + 'Edit playlist': 'Modifica playlist', + 'Update': 'Aggiorna', + 'Playlist updated!': 'Playlist aggiornata!', + 'Downloads added!': 'Download aggiunti!', + 'Save cover file for every track': + "Salva la copertina dell'album per ogni traccia", + 'Download Log': 'Download Log', + 'Repository': 'Repository', + 'Source code, report issues there.': + 'Codice sorgente, segnala i problemi lì.', + + //0.5.2 Strings: + 'Use system theme': 'Utilizza il tema di sistema', + 'Light': 'Chiaro' + } +}; diff --git a/translations/old_languages/ko_ko.dart b/translations/old_languages/ko_ko.dart new file mode 100644 index 0000000..45ed17b --- /dev/null +++ b/translations/old_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 ReFreezer to apply!': + '언어가 변경되었습니다. 적용하려면 Freezer를 다시 시작하세요!', + 'Importing...': '…\$가져 오는 중', + 'Radio': '라디오', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Deezer에서는 트랙을 사용할 수 없습니다!', + 'Failed to download track! Please restart.': '트랙을 다운로드하지 못했습니다! 다시 시작하십시오.', + } +}; diff --git a/translations/old_languages/pt_br.dart b/translations/old_languages/pt_br.dart new file mode 100644 index 0000000..8502c1f --- /dev/null +++ b/translations/old_languages/pt_br.dart @@ -0,0 +1,192 @@ +/* + +Translated by: Diego Hiro + +*/ + +const language_pt_br = { + 'pt_br': { + 'Home': 'Início', + 'Search': 'Pesquisar', + 'Library': 'Biblioteca', + "Offline mode, can't play flow or smart track lists.": + 'Modo offline, incapaz de reproduzir faixas do flow(personalizadas) ou playlist inteligentes.', + 'Added to library': 'Adicionado para sua biblioteca', + 'Download': 'Download', + 'Disk': 'Disco', + 'Offline': 'Offline', + 'Top Tracks': 'Faixas no Top', + 'Show more tracks': 'Exibir mais faixas', + 'Top': 'Top', + 'Top Albums': 'Álbuns no Top', + 'Show all albums': 'Mostrar todos os álbuns', + 'Discography': 'Discografia', + 'Default': 'Padrão', + 'Reverse': 'Reverter', + 'Alphabetic': 'Alfabética', + 'Artist': 'Artista', + 'Post processing...': 'Processando...', + 'Done': 'Feito', + 'Delete': 'Deletar', + 'Are you sure you want to delete this download?': + 'Tem certeza que deseja excluir este download?', + 'Cancel': 'Cancelar', + 'Downloads': 'Downloads', + 'Clear queue': 'Limpar fila', + "This won't delete currently downloading item": + 'Isso não excluirá os itens que estão fazendo download', + 'Are you sure you want to delete all queued downloads?': + 'Tem certeza que deseja excluir todos os downloads que estão na fila?', + 'Clear downloads history': 'Limpar histórico de downloads', + 'WARNING: This will only clear non-offline (external downloads)': + 'Cuidado: Isso limpará apenas faixas e listas off-line (downloads externos)', + 'Please check your connection and try again later...': + 'Verifique sua conexão e tente novamente. Caso sua rede não esteja estável, tente mais tarde...', + 'Show more': 'Mostrar Mais', + 'Importer': 'importador', + 'Currently supporting only Spotify, with 100 tracks limit': + 'Atualmente suportando apenas Spotify, com limite de 100 faixas', + 'Due to API limitations': 'Devido às limitações da API', + 'Enter your playlist link below': + 'Insira o link da sua lista de reprodução abaixo', + 'Error loading URL!': 'Erro ao carregar URL!', + 'Convert': 'Converter', + 'Download only': 'Somente download', + 'Downloading is currently stopped, click here to resume.': + 'O download está parado no momento, clique aqui para retomar.', + 'Tracks': 'Faixas', + 'Albums': 'Álbuns', + 'Artists': 'Artistas', + 'Playlists': 'Playlists', + 'Import': 'Importar', + 'Import playlists from Spotify': 'Importar playlists do Spotify', + 'Statistics': 'Estatísticas', + 'Offline tracks': 'Faixas Offline', + 'Offline albums': 'Álbuns Offline', + 'Offline playlists': 'Playlists Offline', + 'Offline size': 'Espaço ocupado Offline', + 'Free space': 'Espaço livre', + 'Loved tracks': 'Faixas que gostou', + 'Favorites': 'Favoritos', + 'All offline tracks': 'Todas as faixas offline', + 'Create new playlist': 'Criar nova playlist', + 'Cannot create playlists in offline mode': + 'Não é possível criar playlists no modo offline', + 'Error': 'Erro', + 'Error logging in! Please check your token and internet connection and try again.': + 'Erro ao tentar login! Verifique seu token e sua conexão com a Internet, tente novamente.', + 'Dismiss': 'Dispensar', + 'Welcome to': 'Bem-vindo ao', + 'Please login using your Deezer account.': + 'Faça login usando sua conta Deezer.', + 'Login using browser': 'Faça login usando o navegador', + 'Login using token': 'Faça login usando o token', + 'Enter ARL': 'Inserir ARL', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Salvar', + "If you don't have account, you can register on deezer.com for free.": + 'Se você não tem uma conta, pode se registrar em deezer.com gratuitamente.', + 'Open in browser': 'Abra no navegador', + "By using this app, you don't agree with the Deezer ToS": + 'Ao usar este aplicativo, você não concorda com os Termos de Uso com a Deezer', + 'Play next': 'Tocar próxima', + 'Add to queue': 'Adicionar à fila', + 'Add track to favorites': 'Adicionar faixa aos favoritos', + 'Add to playlist': 'Adicionar à Playlist', + 'Select playlist': 'Selecionar playlist', + 'Track added to': 'Faixa adicionada para', + 'Remove from playlist': 'Remover da playlist', + 'Track removed from': 'Faixa removida do(a)', + 'Remove favorite': 'Remover favorito', + 'Track removed from library': 'Faixa removida da biblioteca', + 'Go to': 'Ir para', + 'Make offline': 'Reproduzir offline', + 'Add to library': 'Adicionar à biblioteca', + 'Remove album': 'Remover álbum', + 'Album removed': 'Álbum removido', + 'Remove from favorites': 'Remover do favoritos', + 'Artist removed from library': 'Artista Removido da biblioteca', + 'Add to favorites': 'Adicionar para favoritos', + 'Remove from library': 'Remover da biblioteca', + 'Add playlist to library': 'Adicionar playlist para biblioteca', + 'Added playlist to library': 'Playlist adicionada para biblioteca', + 'Make playlist offline': 'Converter playlist para modo offline', + 'Download playlist': ' Efetuar download da playlist', + 'Create playlist': 'Criar playlist', + 'Title': 'Título', + 'Description': 'Descrição', + 'Private': 'Privado', + 'Collaborative': 'Colaborativo', + 'Create': 'Criar', + 'Playlist created!': 'Playlist criada!', + 'Playing from:': 'Playing de:', + 'Queue': 'Fila', + 'Offline search': 'Pesquisa Offline', + 'Search Results': 'Resultado da pesquisa', + 'No results!': 'Nenhum resultado encontrado!', + 'Show all tracks': 'Mostrar todas as faixas', + 'Show all playlists': 'Mostrar todas playlists', + 'Settings': 'Configurações', + 'General': 'Geral', + 'Appearance': 'Aparência', + 'Quality': 'Qualidade', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Atualmente', + 'Select theme': 'Selecionar tema', + 'Light (default)': 'Claro (Padrão)', + 'Dark': 'Escuro', + 'Black (AMOLED)': 'Preto (AMOLED)', + 'Deezer (Dark)': 'Deezer (Escuro - Dark Mode)', + 'Primary color': 'Cor Primária', + 'Selected color': 'Cor selecionada', + 'Use album art primary color': 'Use a cor primária da capa do álbum', + 'Warning: might be buggy': + 'Cuidado: pode ter erros dependendo do dispositivo', + 'Mobile streaming': 'Streaming por dados móveis', + 'Wifi streaming': 'Streaming por Rede Wifi', + 'External downloads': 'Downloads Externos', + 'Content language': 'Linguagem do conteúdo', + 'Not app language, used in headers. Now': + 'Não é o idioma do aplicativo, programação feita em outra Linguagem. Agora', + 'Select language': 'Selecione a linguagem', + 'Content country': 'País do conteúdo a Exibir', + 'Country used in headers. Now': 'País habilitado no banco de dados. Agora', + 'Log tracks': 'Log de faixas', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Enviar registros de faixas de trilhas para o Deezer, habilite-o para o funcionamento de recursos, como o Flow para funcionar corretamente', + 'Offline mode': 'Modo Offline', + 'Will be overwritten on start.': + 'Será sobrescrito no próximo início do aplicativo.', + 'Error logging in, check your internet connections.': + 'Erro ao fazer login, verifique suas conexões de internet.', + 'Logging in...': 'Logando em...', + 'Download path': 'Caminho de download', + 'Downloads naming': 'Nomenclatura de downloads', + 'Downloaded tracks filename': 'Nome de arquivo das faixas baixadas', + 'Valid variables are': 'Variáveis ​​válidas são', + 'Reset': 'Resetar', + 'Clear': 'Limpar', + 'Create folders for artist': 'Create folders for artist', + 'Create folders for albums': 'Create folders for albums', + 'Separate albums by discs': 'Separate albums by discs', + 'Overwrite already downloaded files': 'Overwrite already downloaded files', + 'Copy ARL': 'Copiar ARL', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copiar userToken/ARL Cookie para uso em outros aplicativos.', + 'Copied': 'Copiado', + 'Log out': 'Deslogar', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Due to plugin incompatibility, login using browser is unavailable without restart.', + '(ARL ONLY) Continue': '(Somente ARL) Continuar', + 'Log out & Exit': 'Deslogar & Sair', + 'Pick-a-Path': 'Escola-um-Caminho', + 'Select storage': 'Selecione o armazenamento', + 'Go up': 'Subir', + 'Permission denied': 'Permissão negada', + 'Language': 'Linguagem', + 'Language changed, please restart ReFreezer to apply!': + 'Idioma alterado, reinicie o ReFreezer para aplicar!', + 'Importing...': 'Importando...' + } +}; diff --git a/translations/old_languages/ro_ro.dart b/translations/old_languages/ro_ro.dart new file mode 100644 index 0000000..3f82d3c --- /dev/null +++ b/translations/old_languages/ro_ro.dart @@ -0,0 +1,237 @@ +/* + +Translated by: MicroMihai + +*/ + +const language_ro_ro = { + 'ro_ro': { + 'Home': 'Home', + 'Search': 'Căutare', + 'Library': 'Librărie', + "Offline mode, can't play flow or smart track lists.": + 'Mod offline, nu pot reda flow-uri sau liste smart track.', + 'Added to library': 'Adăugat la librărie', + 'Download': 'Descărcați', + 'Disk': 'Disc', + 'Offline': 'Offline', + 'Top Tracks': 'Piese Top', + 'Show more tracks': 'Afișează mai multe piese', + 'Top': 'Top', + 'Top Albums': 'Albume Top', + 'Show all albums': 'Afișează toate albumele', + 'Discography': 'Discografie', + 'Default': 'Implicit', + 'Reverse': 'Invers', + 'Alphabetic': 'Alfabetic', + 'Artist': 'Artist', + 'Post processing...': 'Post procesare...', + 'Done': 'Gata', + 'Delete': 'Ștergeți', + 'Are you sure you want to delete this download?': + 'Ești sigur că vrei să ștergi această descărcare?', + 'Cancel': 'Anulează', + 'Downloads': 'Descărcări', + 'Clear queue': 'Ștergeți coada', + "This won't delete currently downloading item": + 'Aceasta nu va șterge elementul care se descarcă acum', + 'Are you sure you want to delete all queued downloads?': + 'Ești sigur că vrei să ștergi toate descărcările aflate în coadă?', + 'Clear downloads history': 'Șterge istoricul descărcărilor', + 'WARNING: This will only clear non-offline (external downloads)': + 'AVERTISMENT: Aceasta va șterge numai non-offline-urile (descărcări externe)', + 'Please check your connection and try again later...': + 'Vă rugăm să verificați conexiunea și să încercați din nou mai târziu...', + 'Show more': 'Arată mai multe', + 'Importer': 'Importator', + 'Currently supporting only Spotify, with 100 tracks limit': + 'În prezent acceptă doar Spotify, cu limita de 100 de piese', + 'Due to API limitations': 'Din cauza limitărilor API', + 'Enter your playlist link below': + 'Introduceți linkul playlistului de mai jos', + 'Error loading URL!': 'Eroare la încărcarea URL-ului!', + 'Convert': 'Convertiți', + 'Download only': 'Doar descărcare', + 'Downloading is currently stopped, click here to resume.': + 'Descărcarea acum este oprită, faceți clic pentru a relua.', + 'Tracks': 'Piese', + 'Albums': 'Albume', + 'Artists': 'Artiști', + 'Playlists': 'Playlist-uri', + 'Import': 'Import', + 'Import playlists from Spotify': 'Importă playlist-uri din Spotify', + 'Statistics': 'Statistici', + 'Offline tracks': 'Piese offline', + 'Offline albums': 'Albume offline', + 'Offline playlists': 'Playlist-uri offline', + 'Offline size': 'Dimensiune offline', + 'Free space': 'Spațiu liber', + 'Loved tracks': 'Piese favorite', + 'Favorites': 'Favorite', + 'All offline tracks': 'Toate piesele offline', + 'Create new playlist': 'Crează un nou playlist', + 'Cannot create playlists in offline mode': + 'Nu se pot crea playlist-uri în modul offline', + 'Error': 'Eroare', + 'Error logging in! Please check your token and internet connection and try again.': + 'Eroare la conectare! Verificați token-ul și conexiunea la internet și încercați din nou.', + 'Dismiss': 'Renunță', + 'Welcome to': 'Bun venit la', + 'Please login using your Deezer account.': + 'Te rugăm să te conectezi utilizând contul tau Deezer.', + 'Login using browser': 'Autentificare utilizând browserul', + 'Login using token': 'Autentificare folosind token-ul', + 'Enter ARL': 'Introduceți ARL-ul', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Salvează', + "If you don't have account, you can register on deezer.com for free.": + 'Dacă nu ai un cont, te poți înregistra gratuit pe deezer.com.', + 'Open in browser': 'Deschide în browser', + "By using this app, you don't agree with the Deezer ToS": + 'Prin utilizarea acestei aplicații, nu sunteți de acord cu Deezer ToS', + 'Play next': 'Redă urmatorul', + 'Add to queue': 'Adaugă la coadă', + 'Add track to favorites': 'Adaugă piesa la favorite', + 'Add to playlist': 'Adaugă la un playlist', + 'Select playlist': 'Selectează playlist-ul', + 'Track added to': 'Piesa a fost adăugată la', + 'Remove from playlist': 'Șterge din playlist', + 'Track removed from': 'Piesa a fost eliminată din', + 'Remove favorite': 'Ștergeți favoritul', + 'Track removed from library': 'Piesa a fost eliminată din librărie', + 'Go to': 'Accesați', + 'Make offline': 'Pune offline', + 'Add to library': 'Adaugă la librărie', + 'Remove album': 'Șterge album-ul', + 'Album removed': 'Album-ul a fost șters', + 'Remove from favorites': 'Șterge din favorite', + 'Artist removed from library': 'Artist șters din librărie', + 'Add to favorites': 'Adaugă la favorite', + 'Remove from library': 'Șterge din librărie', + 'Add playlist to library': 'Adaugă playlist-ul la librărie', + 'Added playlist to library': 'Playlist-ul a fost adăugat la librărie', + 'Make playlist offline': 'Pune playlist-ul offline', + 'Download playlist': 'Descarcă playlist-ul', + 'Create playlist': 'Crează un playlist', + 'Title': 'Titlu', + 'Description': 'Descriere', + 'Private': 'Privat', + 'Collaborative': 'Colaborativ', + 'Create': 'Create', + 'Playlist created!': 'Playlist-ul a fost creat!', + 'Playing from:': 'Redare din:', + 'Queue': 'Coadă', + 'Offline search': 'Căutare offline', + 'Search Results': 'Rezultate găsite', + 'No results!': 'Nici un rezultat', + 'Show all tracks': 'Afișează toate piesele', + 'Show all playlists': 'Afișează toate playlist-urile', + 'Settings': 'Setări', + 'General': 'General', + 'Appearance': 'Aspect', + 'Quality': 'Calitate', + 'Deezer': 'Deezer', + 'Theme': 'Temă', + 'Currently': 'Acum', + 'Select theme': 'Alege tema', + 'Light (default)': 'Aprins (Default)', + 'Dark': 'Întunecat', + 'Black (AMOLED)': 'Negru (AMOLED)', + 'Deezer (Dark)': 'Deezer (Întunecat)', + 'Primary color': 'Culoare primară', + 'Selected color': 'Culoarea selectată', + 'Use album art primary color': 'Utilizați culoarea primară ale album-ului', + 'Warning: might be buggy': 'Avertisment: ar putea fi cam bug-uit', + 'Mobile streaming': 'Streaming mobil', + 'Wifi streaming': 'Streaming Wi-Fi', + 'External downloads': 'Descărcări externe', + 'Content language': 'Limbajul conținutului', + 'Not app language, used in headers. Now': + 'Nu este limba aplicației, folosit în header (titlu). Acum', + 'Select language': 'Alege o limbă', + 'Content country': 'Conținutul tării', + 'Country used in headers. Now': + 'Țara este utilizată în header-i (titluri). Acum', + 'Log tracks': 'Log-ul pieselor', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + 'Trimiteți log-urile de ascultare a pieselor către Deezer, activați-l pentru funcții precum Flow să funcționeze corect', + 'Offline mode': 'Mod offline', + 'Will be overwritten on start.': 'Va fi suprascris la început.', + 'Error logging in, check your internet connections.': + 'Eroare la conectare, verificați conexiunile la internet.', + 'Logging in...': 'Conectare...', + 'Download path': 'Calea descărcărilor', + 'Downloads naming': 'Denumirea descărcărilor', + 'Downloaded tracks filename': 'Numele pieselor descărcate', + 'Valid variables are': 'Variabilele valide sunt', + 'Reset': 'Reset', + 'Clear': 'Șterge', + 'Create folders for artist': 'Creați foldere pentru artiști', + 'Create folders for albums': 'Creați foldere pentru albume', + 'Separate albums by discs': 'Separează albumele după discuri', + 'Overwrite already downloaded files': + 'Suprascrieți fișierele deja descărcate', + 'Copy ARL': 'Copiază ARL-ul', + 'Copy userToken/ARL Cookie for use in other apps.': + 'Copiază userToken-ul/ARL-ul Cookie utilizarea în alte aplicații.', + 'Copied': 'Copiat', + 'Log out': 'Deconectază', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Din cauza incompatibilității plugin-ului, conectarea utilizând browserul nu este disponibilă fără un restart', + '(ARL ONLY) Continue': '(DOAR ARL) Continuă', + 'Log out & Exit': 'Deconectează și ieși', + 'Pick-a-Path': 'Alege o cale', + 'Select storage': 'Selectează stocarea', + 'Go up': 'Du-te sus', + 'Permission denied': 'Permisie refuzată', + 'Language': 'Limbă', + 'Language changed, please restart ReFreezer to apply!': + 'Limba a fost schimbată, restart-ați ReFreezer pentru a aplica schimbarea!', + 'Importing...': 'Importând...', + 'Radio': 'Radio', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Piesa nu este disponibilă pe Deezer!', + 'Failed to download track! Please restart.': + 'Descărcarea piesei nu a reușit! Restart-ați.', + + //0.5.0 Strings: + 'Storage permission denied!': 'Permisia de stocare a fost refuzată!', + 'Failed': 'Eșuat', + 'Queued': 'În coadă', + //Updated in 0.5.1 - used in context of download: + 'External': 'Stocare', + //0.5.0 + 'Restart failed downloads': 'Restart-ați descărcările eșuate', + 'Clear failed': 'Șterge eșuatele', + 'Download Settings': 'Descărcați setările', + 'Create folder for playlist': 'Creați foldere pentru playlist-uri', + 'Download .LRC lyrics': 'Descărcați versurile .LRC', + 'Proxy': 'Proxy', + 'Not set': 'Nu este setat', + 'Search or paste URL': 'Caută sau pune un URL', + 'History': 'Istorie', + //Updated 0.5.1 + 'Download threads': 'Descărcări simultane', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': + 'Versurile nu sunt disponibile, goale sau au eșuat încărcarea!', + 'About': 'Despre', + 'Telegram Channel': 'Canalul Telegram', + 'To get latest releases': 'Pentru a obține cele mai recente versiuni', + 'Official chat': 'Chat-ul oficial', + 'Telegram Group': 'Grupul Telegram', + 'Huge thanks to all the contributors! <3': + 'Mulțumesc frumos tuturor colaboratorilor! <3', + 'Edit playlist': 'Editați playlist-ul', + 'Update': 'Actualizează', + 'Playlist updated!': 'Playlist actualizat!', + 'Downloads added!': 'Descărcări adăugate!', + + //0.5.1 Strings: + 'Save cover file for every track': 'Salvează cover-ul pentru fiecare piesă', + 'Download Log': 'Log-ul descărcării', + 'Repository': 'Depozit', + 'Source code, report issues there.': + 'Codul sursă (Source code), raportați problemele acolo.' + } +}; diff --git a/translations/old_languages/ru_ru.dart b/translations/old_languages/ru_ru.dart new file mode 100644 index 0000000..4a12df5 --- /dev/null +++ b/translations/old_languages/ru_ru.dart @@ -0,0 +1,236 @@ +/* + +Translated by: @Orfej + +*/ +const language_ru_ru = { + 'ru_ru': { + 'Home': 'Главная', + 'Search': 'Поиск', + 'Library': 'Избранное', + "Offline mode, can't play flow or smart track lists.": + 'Режим офлайн. Невозможно воспроизвести персональные подборки', + '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', + 'Due to API limitations': 'Можно импортировать не более 100 треков за раз', + 'Enter your playlist link below': 'Ссылка на плейлист', + 'Error loading URL!': 'Ошибка загрузки!', + 'Convert': 'Импортировать', + 'Download only': 'Скачать', + 'Downloading is currently stopped, click here to resume.': + 'Загрузка приостановлена, нажмите, чтобы продолжить.', + 'Tracks': 'Треки', + 'Albums': 'Альбомы', + 'Artists': 'Артисты', + 'Playlists': '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.': + 'Ошибка входа. Проверьте корректность ARL и соединение с Интернетом', + 'Dismiss': 'Я понял', + 'Welcome to': 'Добро пожаловать в', + 'Please login using your Deezer account.': + 'Войдите, используя свой аккаунт Deezer.', + 'Login using browser': 'Войти через браузер', + 'Login using token': 'Войти по токену (ARL)', + '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.', + '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': + 'Отправлять статистику прослушивания. Необходимо для правильной работы рекомендаций', + '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.': + 'Может быть полезно для использования в других приложениях. Не сообщайте токен никому!', + '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 ReFreezer to apply!': + 'Язык изменен, перезапустите приложения для применения', + 'Importing...': 'Импорт...', + 'Radio': 'Радио', + 'Flow': 'Flow', + 'Track is not available on Deezer!': 'Трек недоступен на Deezer!', + 'Failed to download track! Please restart.': + 'Ошибка заргузки.Попробуйте снова.', + + //0.5.0 Strings: + 'Storage permission denied!': 'Доступ к хранилищу запрещен!', + 'Failed': 'Ошибка', + 'Queued': 'Добавлено в очередь', + //Updated in 0.5.1 - used in context of download: + 'External': 'Хранилище', + //0.5.0 + 'Restart failed downloads': 'Перезапустить загрузки с ошибками', + 'Clear failed': 'Не удалось очистить', + 'Download Settings': 'Настройки загрузок', + 'Create folder for playlist': 'Создавать папки для плейлистов', + 'Download .LRC lyrics': 'Скачивать тексты .LRC', + 'Proxy': 'Настройки прокси', + 'Not set': 'Прокси не настроен', + 'Search or paste URL': 'Введите запрос или ссылку', + 'History': 'История', + //Updated 0.5.1 + 'Download threads': 'Количество одновременных загрузок', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': 'Ошибка получения текста!', + 'About': 'О приложении', + 'Telegram Channel': 'Канал в Telegram', + 'To get latest releases': 'Здесь можно скачать официальные обновления', + 'Official chat': 'Группа в Telegram', + 'Telegram Group': 'Свободное общение о приложении', + 'Huge thanks to all the contributors! <3': + 'Большое спасибо всем участинкам <3', + 'Edit playlist': 'Изменить плейлист', + 'Update': 'Обновить', + 'Playlist updated!': 'Плейлист обновлен!', + 'Downloads added!': 'Загрузки добавлены!', + + //0.5.1 Strings: + 'Save cover file for every track': + 'Обложки для каждого трека отдельным файлом', + 'Download Log': 'Лог загрузок (технические данные)', + 'Repository': 'Репозиторий', + 'Source code, report issues there.': 'Исходный код, вопросы, предложения.', + //0.5.2 Strings: + 'Use system theme': 'Использовать тему системы', + 'Light': 'Светлая' + } +}; diff --git a/translations/old_languages/tr_tr.dart b/translations/old_languages/tr_tr.dart new file mode 100644 index 0000000..ba17c0d --- /dev/null +++ b/translations/old_languages/tr_tr.dart @@ -0,0 +1,241 @@ +/* + +Translated by: HoScHaKaL + +*/ + +const language_tr_tr = { + 'tr_tr': { + 'Kebab': 'Based', + 'Home': 'Anasayfa', + 'Search': 'Ara', + 'Library': 'Kütüphane', + "Offline mode, can't play flow or smart track lists.": + 'Çevrimdışı mod, akış veya akıllı parça listelerini çalınamaz.', + 'Added to library': 'Kütüphaneye eklendi', + 'Download': 'İndir', + 'Disk': 'Disk', + 'Offline': 'Çevrimdışı', + 'Top Tracks': 'En iyi Parçalar', + 'Show more tracks': 'Daha fazla parça göster', + 'Top': 'En iyiler', + 'Top Albums': 'En iyi Albümler', + 'Show all albums': 'Tüm albümleri göster', + 'Discography': 'Diskografi', + 'Default': 'Varsayılan', + 'Reverse': 'Tersten', + 'Alphabetic': 'Alfabetik', + 'Artist': 'Sanatçı', + 'Post processing...': 'İşleniyor...', + 'Done': 'Bitti', + 'Delete': 'Sil', + 'Are you sure you want to delete this download?': + 'Bu indirmeyi silmek istediğinizden emin misiniz?', + 'Cancel': 'İptal', + 'Downloads': 'İndirilenler', + 'Clear queue': 'Sırayı temizle', + "This won't delete currently downloading item": + 'Bu, şu anda indirilen öğeyi silemez', + 'Are you sure you want to delete all queued downloads?': + 'Sıradaki tüm indirmeleri silmek istediğinizden emin misiniz?', + 'Clear downloads history': 'İndirme geçmişini temizle', + 'WARNING: This will only clear non-offline (external downloads)': + 'UYARI: Bu yalnızca çevrimdışı olmayanları temizler (harici indirmeler)', + 'Please check your connection and try again later...': + 'Lütfen bağlantınızı kontrol edin ve daha sonra tekrar deneyin ...', + 'Show more': 'Daha fazla göster', + 'Importer': 'Aktar', + 'Currently supporting only Spotify, with 100 tracks limit': + "Şu anda 100 parça sınırıyla yalnızca Spotify'ı destekliyor", + 'Due to API limitations': 'API sınırlamaları nedeniyle', + 'Enter your playlist link below': + 'Oynatma listesi bağlantınızı aşağıya girin', + 'Error loading URL!': 'URL yüklenirken hata oluştu!', + 'Convert': 'Dönüştür', + 'Download only': 'Sadece indir', + 'Downloading is currently stopped, click here to resume.': + 'İndirme durduruldu , devam etmek için tıklayın.', + 'Tracks': 'Parçalar', + 'Albums': 'Albümler', + 'Artists': 'Sanatçılar', + 'Playlists': 'Oynatma listeleri', + 'Import': 'İçe Aktar', + 'Import playlists from Spotify': + "Spotify'dan çalma listelerini içe aktarın", + 'Statistics': 'İstatistikler', + 'Offline tracks': 'Çevrimdışı parçalar', + 'Offline albums': 'Çevrimdışı albümler', + 'Offline playlists': 'Çevrimdışı oynatma listeleri', + 'Offline size': 'Çevrimdışı boyut', + 'Free space': 'Boş alan', + 'Loved tracks': 'Sevilen parçalar', + 'Favorites': 'Favoriler', + 'All offline tracks': 'Tüm çevrimdışı parçalar', + 'Create new playlist': 'Yeni oynatma listesi oluştur', + 'Cannot create playlists in offline mode': + 'Çevrimdışı modda oynatma listeleri oluşturulamaz', + 'Error': 'Hata', + 'Error logging in! Please check your token and internet connection and try again.': + 'Oturum açılamadı! Lütfen tokeninizi ve internet bağlantınızı kontrol edin ve tekrar deneyin.', + 'Dismiss': 'Kapat', + 'Welcome to': 'Hoşgeldiniz', + 'Please login using your Deezer account.': + 'Lütfen Deezer hesabınızı kullanarak giriş yapın.', + 'Login using browser': 'Tarayıcı kullanarak giriş yapın', + 'Login using token': 'Token kullanarak giriş yap', + 'Enter ARL': 'ARL girin', + 'Token (ARL)': 'Token (ARL)', + 'Save': 'Kaydet', + "If you don't have account, you can register on deezer.com for free.": + "Hesabınız yoksa deezer.com'a ücretsiz kayıt olabilirsiniz.", + 'Open in browser': 'Tarayıcıda aç', + "By using this app, you don't agree with the Deezer ToS": + "Bu uygulamayı kullanarak Deezer Hizmet Şartları'nı kabul etmiyorsunuz", + 'Play next': 'Sonrakini çal', + 'Add to queue': 'Sıraya ekle', + 'Add track to favorites': 'Parçayı favorilere ekle', + 'Add to playlist': 'Oynatma listesine ekle', + 'Select playlist': 'Oynatma listesi seçin', + 'Track added to': 'Parça şuraya eklendi', + 'Remove from playlist': 'Oynatma listesinden kaldır', + 'Track removed from': 'Parça şuradan kaldırıldı', + 'Remove favorite': 'Favorilerden kaldır', + 'Track removed from library': 'Parça kütüphaneden kaldırıldı', + 'Go to': 'Git', + 'Make offline': 'Çevrimdışı yap', + 'Add to library': 'Kütüphaneye ekle', + 'Remove album': 'Albümü kaldır', + 'Album removed': 'Albüm kaldırıldı', + 'Remove from favorites': 'Favorilerden kaldır', + 'Artist removed from library': 'Sanatçı kütüphaneden kaldırıldı', + 'Add to favorites': 'Favorilere ekle', + 'Remove from library': 'Kütüphaneden kaldır', + 'Add playlist to library': 'Oynatma listesini kütüphaneye ekleyin', + 'Added playlist to library': 'Oynatma listesi kütüphaneye eklendi', + 'Make playlist offline': 'Oynatma listesini çevrimdışı yapın', + 'Download playlist': 'Oynatma listesini indirin', + 'Create playlist': 'Oynatma listesi oluştur', + 'Title': 'Başlık', + 'Description': 'Açıklama', + 'Private': 'Özel', + 'Collaborative': 'Paylaşılan', + 'Create': 'Oluştur', + 'Playlist created!': 'Oynatma listesi oluşturuldu!', + 'Playing from:': 'Şuradan oynatılıyor:', + 'Queue': 'Kuyruk', + 'Offline search': 'Çevrimdışı arama', + 'Search Results': 'Arama Sonuçları', + 'No results!': 'Sonuç yok!', + 'Show all tracks': 'Tüm parçaları göster', + 'Show all playlists': 'Tüm oynatma listelerini göster', + 'Settings': 'Ayarlar', + 'General': 'Genel', + 'Appearance': 'Arayüz', + 'Quality': 'Kalite', + 'Deezer': 'Deezer', + 'Theme': 'Tema', + 'Currently': 'Şu anda', + 'Select theme': 'Tema seçin', + 'Light (default)': 'Açık (Varsayılan)', + 'Dark': 'Koyu', + 'Black (AMOLED)': 'Siyah (AMOLED)', + 'Deezer (Dark)': 'Deezer (Dark)', + 'Primary color': 'Ana renk', + 'Selected color': 'Seçilen renk', + 'Use album art primary color': 'Albüm resmini ana renk olarak kullan', + 'Warning: might be buggy': 'Uyarı: hatalı olabilir', + 'Mobile streaming': 'Mobil veri', + 'Wifi streaming': 'Wifi', + 'External downloads': 'Harici indirmeler', + 'Content language': 'İçerik dili', + 'Not app language, used in headers. Now': + 'Uygulama dili değil, başlıklarda kullanılacak. Şuan', + 'Select language': 'Dil seçin', + 'Content country': 'İçerik ülkesi', + 'Country used in headers. Now': 'Başlıklarda kullanılan ülke. Şuan', + 'Log tracks': 'Parça günlükleri', + 'Send track listen logs to Deezer, enable it for features like Flow to work properly': + "Parça dinleme günlüklerini Deezer'a gönderin, Flow gibi özelliklerin düzgün çalışması için etkinleştirin", + 'Offline mode': 'Çevrimdışı mod', + 'Will be overwritten on start.': 'Başlangıçta üzerine yazılacak.', + 'Error logging in, check your internet connections.': + 'Giriş hatası, internet bağlantılarınızı kontrol edin.', + 'Logging in...': 'Giriş yapılıyor...', + 'Download path': 'İndirme konumu', + 'Downloads naming': 'İndirilenleri adlandır', + 'Downloaded tracks filename': 'İndirilen parçaların dosya adı', + 'Valid variables are': 'Geçerli değişkenler', + 'Reset': 'Sıfırla', + 'Clear': 'Temizle', + 'Create folders for artist': 'Sanatçılar için klasörler oluşturun', + 'Create folders for albums': 'Albümler için klasörler oluşturun', + 'Separate albums by discs': 'Albümleri disklere göre ayırın', + 'Overwrite already downloaded files': 'İndirilmiş dosyaların üzerine yaz', + 'Copy ARL': 'ARL kopyala', + 'Copy userToken/ARL Cookie for use in other apps.': + "Diğer uygulamalarda kullanmak için userToken / ARL Cookie'yi kopyalayın.", + 'Copied': 'Kopyalandı', + 'Log out': 'Çıkış yap', + 'Due to plugin incompatibility, login using browser is unavailable without restart.': + 'Eklenti uyumsuzluğu nedeniyle, yeniden başlatmadan tarayıcı kullanılarak oturum açılamaz.', + '(ARL ONLY) Continue': '(SADECE ARL) Devam et', + 'Log out & Exit': 'Çıkış yap & Kapat', + 'Pick-a-Path': 'Konum seç', + 'Select storage': 'Depolama seç', + 'Go up': 'Yukarı git', + 'Permission denied': 'İzin reddedildi', + 'Language': 'Dil', + 'Language changed, please restart ReFreezer to apply!': + 'Dil değişti,değişiklik için Freezeri yeniden başlatın!', + 'Importing...': 'İçe aktarılıyor...', + 'Radio': 'Radyo', + 'Flow': 'Flow', + 'Track is not available on Deezer!': "Parça Deezer'da mevcut değil!", + 'Failed to download track! Please restart.': + 'Parça indirilemedi! Lütfen yeniden başlat.', + + //0.5.0 Strings: + 'Storage permission denied!': 'Depolama izni reddedildi!', + 'Failed': 'Başarısız', + 'Queued': 'Sıraya alındı', + //Updated in 0.5.1 - used in context of download: + 'External': 'Depolama', + //0.5.0 + 'Restart failed downloads': 'Başarısız indirmeleri yeniden başlatın', + 'Clear failed': 'Silinemedi', + 'Download Settings': 'İndirme Ayarları', + 'Create folder for playlist': 'Oynatma listesi için klasör oluştur', + 'Download .LRC lyrics': '.LRC şarkı sözlerini indir', + 'Proxy': 'Proxy', + 'Not set': 'Ayarlanmadı', + 'Search or paste URL': 'Arayın veya URL yapıştırın', + 'History': 'Geçmiş', + //Updated 0.5.1 + 'Download threads': 'Eşzamanlı indirmeler', + //0.5.0 + 'Lyrics unavailable, empty or failed to load!': + 'Sözler mevcut değil, boş veya yüklenemedi!', + 'About': 'Hakkında', + 'Telegram Channel': 'Telegram Kanalı', + 'To get latest releases': 'En son sürümleri indirmek için', + 'Official chat': 'Resmi sohbet', + 'Telegram Group': 'Telegram Grubu', + 'Huge thanks to all the contributors! <3': + 'Katkıda bulunanlara çok teşekkürler! <3', + 'Edit playlist': 'Oynatma listesini düzenleyin', + 'Update': 'Güncelle', + 'Playlist updated!': 'Oynatma listesi güncellendi!', + 'Downloads added!': 'İndirmeler eklendi!', + + //0.5.1 Strings: + 'Save cover file for every track': + 'Her parça için kapak dosyasını kaydedin', + 'Download Log': 'İndirme Kayıtları', + 'Repository': 'Repo', + 'Source code, report issues there.': 'Kaynak kodu, sorunları bildirin', + + //0.5.2 Strings: + 'Use system theme': 'Sistem temasını kullan', + 'Light': 'Açık' + } +}; diff --git a/translations/refreezer.json b/translations/refreezer.json new file mode 100644 index 0000000..500e0d3 --- /dev/null +++ b/translations/refreezer.json @@ -0,0 +1,206 @@ +{ + "Home": "Home", + "Search": "Search", + "Library": "Library", + "Offline mode, can't play flow or smart track lists.": "Offline mode, can't play flow or smart track lists.", + "Added to library": "Added to library", + "Download": "Download", + "Disk": "Disk", + "Offline": "Offline", + "Top Tracks": "Top Tracks", + "Show more tracks": "Show more tracks", + "Top": "Top", + "Top Albums": "Top Albums", + "Show all albums": "Show all albums", + "Discography": "Discography", + "Default": "Default", + "Reverse": "Reverse", + "Alphabetic": "Alphabetic", + "Artist": "Artist", + "Post processing...": "Post processing...", + "Done": "Done", + "Delete": "Delete", + "Are you sure you want to delete this download?": "Are you sure you want to delete this download?", + "Cancel": "Cancel", + "Downloads": "Downloads", + "Clear queue": "Clear queue", + "This won't delete currently downloading item": "This won't delete currently downloading item", + "Are you sure you want to delete all queued downloads?": "Are you sure you want to delete all queued downloads?", + "Clear downloads history": "Clear downloads history", + "WARNING: This will only clear non-offline (external downloads)": "WARNING: This will only clear non-offline (external downloads)", + "Please check your connection and try again later...": "Please check your connection and try again later...", + "Show more": "Show more", + "Importer": "Importer", + "Currently supporting only Spotify, with 100 tracks limit": "Currently supporting only Spotify, with 100 tracks limit", + "Due to API limitations": "Due to API limitations", + "Enter your playlist link below": "Enter your playlist link below", + "Error loading URL!": "Error loading URL!", + "Convert": "Convert", + "Download only": "Download only", + "Downloading is currently stopped, click here to resume.": "Downloading is currently stopped, click here to resume.", + "Tracks": "Tracks", + "Albums": "Albums", + "Artists": "Artists", + "Playlists": "Playlists", + "Import": "Import", + "Import playlists from Spotify": "Import playlists from Spotify", + "Statistics": "Statistics", + "Offline tracks": "Offline tracks", + "Offline albums": "Offline albums", + "Offline playlists": "Offline playlists", + "Offline size": "Offline size", + "Free space": "Free space", + "Loved tracks": "Loved tracks", + "Favorites": "Favorites", + "All offline tracks": "All offline tracks", + "Create new playlist": "Create new playlist", + "Cannot create playlists in offline mode": "Cannot create playlists in offline mode", + "Error": "Error", + "Error logging in! Please check your token and internet connection and try again.": "Error logging in! Please check your token and internet connection and try again.", + "Dismiss": "Dismiss", + "Welcome to": "Welcome to", + "Please login using your Deezer account.": "Please login using your Deezer account.", + "Login using browser": "Login using browser", + "Login using token": "Login using token", + "Enter ARL": "Enter ARL", + "Token (ARL)": "Token (ARL)", + "Save": "Save", + "If you don't have account, you can register on deezer.com for free.": "If you don't have account, you can register on deezer.com for free.", + "Open in browser": "Open in browser", + "By using this app, you don't agree with the Deezer ToS": "By using this app, you don't agree with the Deezer ToS", + "Play next": "Play next", + "Add to queue": "Add to queue", + "Add track to favorites": "Add track to favorites", + "Add to playlist": "Add to playlist", + "Select playlist": "Select playlist", + "Track added to": "Track added to", + "Remove from playlist": "Remove from playlist", + "Track removed from": "Track removed from", + "Remove favorite": "Remove favorite", + "Track removed from library": "Track removed from library", + "Go to": "Go to", + "Make offline": "Make offline", + "Add to library": "Add to library", + "Remove album": "Remove album", + "Album removed": "Album removed", + "Remove from favorites": "Remove from favorites", + "Artist removed from library": "Artist removed from library", + "Add to favorites": "Add to favorites", + "Remove from library": "Remove from library", + "Add playlist to library": "Add playlist to library", + "Added playlist to library": "Added playlist to library", + "Make playlist offline": "Make playlist offline", + "Download playlist": "Download playlist", + "Create playlist": "Create playlist", + "Title": "Title", + "Description": "Description", + "Private": "Private", + "Collaborative": "Collaborative", + "Create": "Create", + "Playlist created!": "Playlist created!", + "Playing from:": "Playing from:", + "Queue": "Queue", + "Offline search": "Offline search", + "Search Results": "Search Results", + "No results!": "No results!", + "Show all tracks": "Show all tracks", + "Show all playlists": "Show all playlists", + "Settings": "Settings", + "General": "General", + "Appearance": "Appearance", + "Quality": "Quality", + "Deezer": "Deezer", + "Theme": "Theme", + "Currently": "Currently", + "Select theme": "Select theme", + "Dark": "Dark", + "Black (AMOLED)": "Black (AMOLED)", + "Deezer (Dark)": "Deezer (Dark)", + "Primary color": "Primary color", + "Selected color": "Selected color", + "Use album art primary color": "Use album art primary color", + "Warning: might be buggy": "Warning: might be buggy", + "Mobile streaming": "Mobile streaming", + "Wifi streaming": "Wifi streaming", + "External downloads": "External downloads", + "Content language": "Content language", + "Not app language, used in headers. Now": "Not app language, used in headers. Now", + "Select language": "Select language", + "Content country": "Content country", + "Country used in headers. Now": "Country used in headers. Now", + "Log tracks": "Log tracks", + "Send track listen logs to Deezer, enable it for features like Flow to work properly": "Send track listen logs to Deezer, enable it for features like Flow to work properly", + "Offline mode": "Offline mode", + "Will be overwritten on start.": "Will be overwritten on start.", + "Error logging in, check your internet connections.": "Error logging in, check your internet connections.", + "Logging in...": "Logging in...", + "Download path": "Download path", + "Downloads naming": "Downloads naming", + "Downloaded tracks filename": "Downloaded tracks filename", + "Valid variables are": "Valid variables are", + "Reset": "Reset", + "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 disks", + "Overwrite already downloaded files": "Overwrite already downloaded files", + "Copy ARL": "Copy ARL", + "Copy userToken/ARL Cookie for use in other apps.": "Copy userToken/ARL Cookie for use in other apps.", + "Copied": "Copied", + "Log out": "Log out", + "Due to plugin incompatibility, login using browser is unavailable without restart.": "Due to plugin incompatibility, login using browser is unavailable without restart.", + "(ARL ONLY) Continue": "(ARL ONLY) Continue", + "Log out & Exit": "Log out & Exit", + "Pick-a-Path": "Pick-a-Path", + "Select storage": "Select storage", + "Go up": "Go up", + "Permission denied": "Permission denied", + "Language": "Language", + "Language changed, please restart ReFreezer to apply!": "Language changed, please restart ReFreezer to apply!", + "Importing...": "Importing...", + "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.", + "Storage permission denied!": "Storage permission denied!", + "Failed": "Failed", + "Queued": "Queued", + "External": "Storage", + "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": "Concurrent downloads", + "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!", + "Save cover file for every track": "Save cover file for every track", + "Download Log": "Download Log", + "Repository": "Repository", + "Source code, report issues there.": "Source code, report issues there.", + "Use system theme": "Use system theme", + "Light": "Light", + "Popularity": "Popularity", + "User": "User", + "Track count": "Track count", + "If you want to use custom directory naming - use '/' as directory separator.": "If you want to use custom directory naming - use '/' as directory separator.", + "Share": "Share", + "Save album cover": "Save album cover", + "Warning": "Warning", + "Using too many concurrent downloads on older/weaker devices might cause crashes!": "Using too many concurrent downloads on older/weaker devices might cause crashes!", + "Create .nomedia files": "Create .nomedia files", + "To prevent gallery being filled with album art": "To prevent gallery being filled with album art" +}