commit ed087bc583d6f6ae0d25141f63add49ac9aae412 Author: exttex Date: Tue Jun 23 21:23:12 2020 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f66c9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +#Key stuff +freezerkey.jsk +android/key.properties + +just_audio/ + +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ +android/.idea/ + +# 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/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/.gradle/5.2.1/fileChanges/last-build.bin b/.gradle/5.2.1/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/5.2.1/fileChanges/last-build.bin differ diff --git a/.gradle/5.2.1/fileHashes/fileHashes.lock b/.gradle/5.2.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000..fec4da3 Binary files /dev/null and b/.gradle/5.2.1/fileHashes/fileHashes.lock differ diff --git a/.gradle/5.2.1/gc.properties b/.gradle/5.2.1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..7d86389 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..6e471d7 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Tue May 26 10:01:04 CEST 2020 +gradle.version=5.2.1 diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..6ac2303 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# 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 and should not be manually edited. + +version: + revision: f7a6a7906be96d2288f5d63a5a54c515a6e987fe + channel: stable + +project_type: app diff --git a/README.md b/README.md new file mode 100644 index 0000000..99b7b88 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# freezer + +A music streaming app written from scratch, which uses Deezer as backend. +This app is still in BETA, so it is missing features and contains bugs. +If you want to report bug or request feature, please open an issue. + +## Downloads: +Under releases tab +**You might get Play Protect warning - just select install anyway or disable Play Protect** - it is because the keys used for signing this app are new. + +## Compile from source + +Install flutter SDK: https://flutter.dev/docs/get-started/install +(Optional) Generate keys for release build: https://flutter.dev/docs/deployment/android +Compile: +``` +flutter pub get +flutter build apk +``` + +## just_audio +This app depends on modified just_audio plugin with Deezer support. Repo: https://notabug.org/exttex/just_audio + + +## Disclaimer +``` +Freezer was not developed for piracy, but educational and private usage. +It may be illegal to use this in your country! +I am not responsible in any way for the usage of this app. +``` + +## Credits +Tobs: Beta tester +Deemix: https://notabug.org/RemixDev/deemix +just_audio: https://github.com/ryanheise/just_audio + +## Support me +BTC: `14hcr4PGbgqeXd3SoXY9QyJFNpyurgrL9y` +ETH: `0xb4D1893195404E1F4b45e5BDA77F202Ac4012288` \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..54f92d6 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +key.properties \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..9d6c556 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,76 @@ +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 flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 28 + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "f.f.freezer" + minSdkVersion 20 + targetSdkVersion 28 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.release + shrinkResources false + minifyEnabled false + } + } +} + +dependencies { + implementation group: 'org', name: 'jaudiotagger', version: '2.0.3' +} + +flutter { + source '../..' +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..5cad113 --- /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..c6b0906 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/f/f/freezer/MainActivity.java b/android/app/src/main/java/f/f/freezer/MainActivity.java new file mode 100644 index 0000000..a6e1005 --- /dev/null +++ b/android/app/src/main/java/f/f/freezer/MainActivity.java @@ -0,0 +1,167 @@ +package f.f.freezer; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.jaudiotagger.audio.AudioFile; +import org.jaudiotagger.audio.AudioFileIO; +import org.jaudiotagger.tag.FieldKey; +import org.jaudiotagger.tag.Tag; +import org.jaudiotagger.tag.TagOptionSingleton; +import org.jaudiotagger.tag.datatype.Artwork; +import org.jaudiotagger.tag.flac.FlacTag; +import org.jaudiotagger.tag.id3.ID3v23Tag; +import org.jaudiotagger.tag.id3.valuepair.ImageFormats; +import org.jaudiotagger.tag.reference.PictureTypes; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.RandomAccessFile; +import java.security.MessageDigest; +import java.util.function.Function; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.MethodChannel; + +public class MainActivity extends FlutterActivity { + private static final String CHANNEL = "f.f.freezer/native"; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL) + .setMethodCallHandler(((call, result) -> { + //Decrypt track + if (call.method.equals("decryptTrack")) { + String tid = call.argument("id").toString(); + String path = call.argument("path"); + decryptTrack(path, tid); + result.success(0); + } + //Add tags to track + if (call.method.equals("tagTrack")) { + try { + //Tag + TagOptionSingleton.getInstance().setAndroid(true); + AudioFile f = AudioFileIO.read(new File(call.argument("path").toString())); + boolean isFlac = true; + if (f.getAudioHeader().getFormat().contains("MPEG")) { + f.setTag(new ID3v23Tag()); + isFlac = false; + } + + Tag tag = f.getTag(); + tag.setField(FieldKey.TITLE, call.argument("title").toString()); + tag.setField(FieldKey.ALBUM, call.argument("album").toString()); + tag.setField(FieldKey.ARTIST, call.argument("artists").toString()); + tag.setField(FieldKey.TRACK, call.argument("trackNumber").toString()); + + //Album art + String cover = call.argument("cover").toString(); + if (isFlac) { + //FLAC requires different cover adding + RandomAccessFile imageFile = new RandomAccessFile(new File(cover), "r"); + byte[] imageData = new byte[(int) imageFile.length()]; + imageFile.read(imageData); + tag.setField(((FlacTag) tag).createArtworkField( + imageData, + PictureTypes.DEFAULT_ID, + ImageFormats.MIME_TYPE_JPG, + "cover", + 1400, + 1400, + 24, + 0 + )); + } else { + //MP3 + Artwork art = Artwork.createArtworkFromFile(new File(cover)); + tag.addField(art); + } + //Save + AudioFileIO.write(f); + + } catch (Exception e) { + e.printStackTrace(); + } + result.success(null); + } + + })); + } + + public static void decryptTrack(String path, String tid) { + try { + //Load file + File inputFile = new File(path + ".ENC"); + BufferedInputStream buffin = new BufferedInputStream(new FileInputStream(inputFile)); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] key = getKey(tid); + for (int i=0; i>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + + //Calculate decryption key from track id + public static byte[] getKey(String id) { + String secret = "g4el58wc0zvf9na1"; + String key = ""; + try { + MessageDigest md5 = MessageDigest.getInstance("MD5"); + //md5.update(id.getBytes()); + byte[] md5id = md5.digest(id.getBytes()); + String idmd5 = bytesToHex(md5id).toLowerCase(); + + for(int i=0; i<16; i++) { + int s0 = idmd5.charAt(i); + int s1 = idmd5.charAt(i+16); + int s2 = secret.charAt(i); + key += (char)(s0^s1^s2); + } + } catch (Exception e) { + } + return key.getBytes(); + } + + //Decrypt 2048b chunk + public static byte[] decryptChunk(byte[] key, byte[] data) throws Exception{ + byte[] IV = {00, 01, 02, 03, 04, 05, 06, 07}; + SecretKeySpec Skey = new SecretKeySpec(key, "Blowfish"); + Cipher cipher = Cipher.getInstance("Blowfish/CBC/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, Skey, new javax.crypto.spec.IvParameterSpec(IV)); + return cipher.doFinal(data); + } +} diff --git a/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..ebe557e 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-hdpi/ic_pause.png b/android/app/src/main/res/drawable-hdpi/ic_pause.png new file mode 100644 index 0000000..4e6adca Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play_arrow.png b/android/app/src/main/res/drawable-hdpi/ic_play_arrow.png new file mode 100644 index 0000000..e356235 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play_arrow.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_skip_next.png b/android/app/src/main/res/drawable-hdpi/ic_skip_next.png new file mode 100644 index 0000000..d51c166 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_skip_next.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_skip_previous.png b/android/app/src/main/res/drawable-hdpi/ic_skip_previous.png new file mode 100644 index 0000000..8621bb6 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_skip_previous.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_stop.png b/android/app/src/main/res/drawable-hdpi/ic_stop.png new file mode 100644 index 0000000..f16f002 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_stop.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..d5bb07a 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-mdpi/ic_pause.png b/android/app/src/main/res/drawable-mdpi/ic_pause.png new file mode 100644 index 0000000..cdb6c67 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play_arrow.png b/android/app/src/main/res/drawable-mdpi/ic_play_arrow.png new file mode 100644 index 0000000..c19fc5a Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play_arrow.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_skip_next.png b/android/app/src/main/res/drawable-mdpi/ic_skip_next.png new file mode 100644 index 0000000..dc8a813 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_skip_next.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_skip_previous.png b/android/app/src/main/res/drawable-mdpi/ic_skip_previous.png new file mode 100644 index 0000000..6d8e782 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_skip_previous.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stop.png b/android/app/src/main/res/drawable-mdpi/ic_stop.png new file mode 100644 index 0000000..e51c5e7 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_stop.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..e535ac2 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-xhdpi/ic_pause.png b/android/app/src/main/res/drawable-xhdpi/ic_pause.png new file mode 100644 index 0000000..e631f09 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play_arrow.png b/android/app/src/main/res/drawable-xhdpi/ic_play_arrow.png new file mode 100644 index 0000000..3cac8d4 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play_arrow.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_skip_next.png b/android/app/src/main/res/drawable-xhdpi/ic_skip_next.png new file mode 100644 index 0000000..aa54bd3 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_skip_next.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_skip_previous.png b/android/app/src/main/res/drawable-xhdpi/ic_skip_previous.png new file mode 100644 index 0000000..f14e099 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_skip_previous.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stop.png b/android/app/src/main/res/drawable-xhdpi/ic_stop.png new file mode 100644 index 0000000..5252573 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_stop.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..ccdce98 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-xxhdpi/ic_pause.png b/android/app/src/main/res/drawable-xxhdpi/ic_pause.png new file mode 100644 index 0000000..7638872 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play_arrow.png b/android/app/src/main/res/drawable-xxhdpi/ic_play_arrow.png new file mode 100644 index 0000000..ecf4f8a Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play_arrow.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_skip_next.png b/android/app/src/main/res/drawable-xxhdpi/ic_skip_next.png new file mode 100644 index 0000000..24365b8 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_skip_next.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_skip_previous.png b/android/app/src/main/res/drawable-xxhdpi/ic_skip_previous.png new file mode 100644 index 0000000..31dbbf8 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_skip_previous.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stop.png b/android/app/src/main/res/drawable-xxhdpi/ic_stop.png new file mode 100644 index 0000000..a680383 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_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..d01c567 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-xxxhdpi/ic_pause.png b/android/app/src/main/res/drawable-xxxhdpi/ic_pause.png new file mode 100644 index 0000000..85a177e Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_play_arrow.png b/android/app/src/main/res/drawable-xxxhdpi/ic_play_arrow.png new file mode 100644 index 0000000..5c9e5ca Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_play_arrow.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_skip_next.png b/android/app/src/main/res/drawable-xxxhdpi/ic_skip_next.png new file mode 100644 index 0000000..328c48d Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_skip_next.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_skip_previous.png b/android/app/src/main/res/drawable-xxxhdpi/ic_skip_previous.png new file mode 100644 index 0000000..7035b0f Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_skip_previous.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stop.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stop.png new file mode 100644 index 0000000..5851a9f Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_stop.png differ 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-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..e2fe9c3 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-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d253b43 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-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..970aaae 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-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0329859 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-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..3d7313e 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/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/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..5cad113 --- /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..11e3d09 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.6.3' + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..a6d680e --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +#android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4b82974 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat May 23 20:49:20 CEST 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..5a2f14f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/android/settings_aar.gradle b/android/settings_aar.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' 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/fonts/Jost-Italic.ttf b/assets/fonts/Jost-Italic.ttf new file mode 100644 index 0000000..5b38c72 Binary files /dev/null and b/assets/fonts/Jost-Italic.ttf differ diff --git a/assets/fonts/Montserrat-Bold.ttf b/assets/fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000..221819b Binary files /dev/null and b/assets/fonts/Montserrat-Bold.ttf differ diff --git a/assets/fonts/Montserrat-Italic.ttf b/assets/fonts/Montserrat-Italic.ttf new file mode 100644 index 0000000..eb4232a Binary files /dev/null and b/assets/fonts/Montserrat-Italic.ttf differ diff --git a/assets/fonts/Montserrat-Regular.ttf b/assets/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..8d443d5 Binary files /dev/null and b/assets/fonts/Montserrat-Regular.ttf differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..e96ef60 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..6b4c0f7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b53de6b --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,506 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + ); + name = "Supporting Files"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = f.f.freezer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = f.f.freezer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = f.f.freezer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a28140c --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..28c6bf0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..f091b6b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cde121 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..d0ef06e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..dcdc230 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..c8f9ed8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..75b2d16 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..c4df70d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..6a84f41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..d0e1f58 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..090a3fb --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + freezer + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart new file mode 100644 index 0000000..3f6eb4c --- /dev/null +++ b/lib/api/deezer.dart @@ -0,0 +1,356 @@ +import 'package:dio/dio.dart'; +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; + +import 'dart:io'; +import 'dart:convert'; + +import '../settings.dart'; +import 'definitions.dart'; + +DeezerAPI deezerAPI = DeezerAPI(); + +class DeezerAPI { + + String arl; + + DeezerAPI({this.arl}); + + String token; + String userId; + String favoritesPlaylistId; + String privateUrl = 'http://www.deezer.com/ajax/gw-light.php'; + Map headers = { + "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", + "Content-Language": '${settings.deezerLanguage??"en"}-${settings.deezerCountry??'US'}', + "Cache-Control": "max-age=0", + "Accept": "*/*", + "Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3", + "Accept-Language": "${settings.deezerLanguage??"en"}-${settings.deezerCountry??'US'},${settings.deezerLanguage??"en"};q=0.9,en-US;q=0.8,en;q=0.7", + "Connection": "keep-alive" + }; + + CookieJar _cookieJar = new CookieJar(); + + //Call private api + Future> callApi(String method, {Map params, String gatewayInput}) async { + Dio dio = Dio(); + + //Add headers + dio.interceptors.add(InterceptorsWrapper( + onRequest: (RequestOptions options) { + options.headers = this.headers; + return options; + } + )); + //Add cookies + List cookies = [Cookie('arl', this.arl)]; + _cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies); + dio.interceptors.add(CookieManager(_cookieJar)); + //Make request + Response response = await dio.post( + this.privateUrl, + queryParameters: { + 'api_version': '1.0', + 'api_token': this.token, + 'input': '3', + 'method': method, + + //Used for homepage + if (gatewayInput != null) + 'gateway_input': gatewayInput + }, + data: jsonEncode(params??{}), + options: Options(responseType: ResponseType.json, sendTimeout: 7000, receiveTimeout: 7000) + ); + return response.data; + } + + //Authorize, bool = success + Future authorize() async { + try { + Map data = await callApi('deezer.getUserData'); + if (data['results']['USER']['USER_ID'] == 0) { + return false; + } else { + this.token = data['results']['checkForm']; + this.userId = data['results']['USER']['USER_ID'].toString(); + this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID']; + return true; + } + } catch (e) { return false; } + } + + //Search + Future search(String query) async { + Map data = await callApi('deezer.pageSearch', params: { + 'nb': 50, + 'query': query, + 'start': 0 + }); + return SearchResults.fromPrivateJson(data['results']); + } + + Future track(String id) async { + Map data = await callApi('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 callApi('deezer.pageAlbum', params: { + 'alb_id': id, + 'header': true, + 'lang': 'us' + }); + return Album.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']); + } + + //Get artist details + Future artist(String id) async { + Map data = await callApi('deezer.pageArtist', params: { + 'art_id': id, + 'lang': 'us', + }); + return Artist.fromPrivateJson( + data['results']['DATA'], + topJson: data['results']['TOP'], + albumsJson: data['results']['ALBUMS'] + ); + } + + //Get playlist tracks at offset + Future> playlistTracksPage(String id, int start, {int nb = 50}) async { + Map data = await callApi('deezer.pagePlaylist', params: { + 'playlist_id': id, + 'lang': 'us', + '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 callApi('deezer.pagePlaylist', params: { + 'playlist_id': id, + 'lang': 'us', + '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 { + Playlist p = await playlist(id, nb: 200); + for (int i=200; i tracks = await playlistTracksPage(id, i, nb: 200); + p.tracks.addAll(tracks); + i += 200; + continue; + } + return p; + } + + //Add track to favorites + Future addFavoriteTrack(String id) async { + await callApi('favorite_song.add', params: {'SNG_ID': id}); + } + + //Add album to favorites/library + Future addFavoriteAlbum(String id) async { + await callApi('album.addFavorite', params: {'ALB_ID': id}); + } + + //Add artist to favorites/library + Future addFavoriteArtist(String id) async { + await callApi('artist.addFavorite', params: {'ART_ID': id}); + } + + //Remove artist from favorites/library + Future removeArtist(String id) async { + await callApi('artist.deleteFavorite', params: {'ART_ID': id}); + } + + //Add tracks to playlist + Future addToPlaylist(String trackId, String playlistId, {int offset = -1}) async { + await callApi('playlist.addSongs', params: { + 'offset': offset, + 'playlist_id': playlistId, + 'songs': [[trackId, 0]] + }); + } + + //Remove track from playlist + Future removeFromPlaylist(String trackId, String playlistId) async { + await callApi('playlist.deleteSongs', params: { + 'playlist_id': playlistId, + 'songs': [[trackId, 0]] + }); + } + + //Get users playlists + Future> getPlaylists() async { + Map data = await callApi('deezer.pageProfile', params: { + 'nb': 100, + 'tab': 'playlists', + 'user_id': this.userId + }); + return data['results']['TAB']['playlists']['data'].map((json) => Playlist.fromPrivateJson(json, library: true)).toList(); + } + + //Get favorite albums + Future> getAlbums() async { + Map data = await callApi('deezer.pageProfile', params: { + 'nb': 50, + 'tab': 'albums', + 'user_id': this.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 callApi('album.deleteFavorite', params: { + 'ALB_ID': id + }); + } + + //Remove track from favorites + Future removeFavorite(String id) async { + await callApi('favorite_song.remove', params: { + 'SNG_ID': id + }); + } + + //Get favorite artists + Future> getArtists() async { + Map data = await callApi('deezer.pageProfile', params: { + 'nb': 40, + 'tab': 'artists', + 'user_id': this.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 { + Map data = await callApi('song.getLyrics', params: { + 'sng_id': trackId + }); + if (data['error'] != null && data['error'].length > 0) return Lyrics().error; + return Lyrics.fromPrivateJson(data['results']); + } + + Future smartTrackList(String id) async { + Map data = await callApi('deezer.pageSmartTracklist', params: { + 'smarttracklist_id': id + }); + return SmartTrackList.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']); + } + + Future> flow() async { + Map data = await callApi('radio.getUserRadio', params: { + 'user_id': userId + }); + 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 callApi('page.get', gatewayInput: jsonEncode({ + "PAGE": "home", + "VERSION": "2.3", + "SUPPORT": { + /* + "deeplink-list": ["deeplink"], + "list": ["episode"], + "grid-preview-one": grid, + "grid-preview-two": grid, + "slideshow": grid, + "message": ["call_onboarding"], + */ + "grid": grid, + "horizontal-grid": grid, + "item-highlight": ["radio"], + "large-card": ["album", "playlist", "show", "video-link"], + "ads": [] //Nope + }, + "LANG": "us", + "OPTIONS": [] + })); + return HomePage.fromPrivateJson(data['results']); + } + + //Log song listen to deezer + Future logListen(String trackId) async { + await callApi('log.listen', params: {'next_media': {'media': {'id': trackId, 'type': 'song'}}}); + } + + Future getChannel(String target) async { + List grid = ['album', 'artist', 'channel', 'flow', 'playlist', 'radio', 'show', 'smarttracklist', 'track', 'user']; + Map data = await callApi('page.get', gatewayInput: jsonEncode({ + 'PAGE': target, + "VERSION": "2.3", + "SUPPORT": { + /* + "deeplink-list": ["deeplink"], + "list": ["episode"], + "grid-preview-one": grid, + "grid-preview-two": grid, + "slideshow": grid, + "message": ["call_onboarding"], + */ + "grid": grid, + "horizontal-grid": grid, + "item-highlight": ["radio"], + "large-card": ["album", "playlist", "show", "video-link"], + "ads": [] //Nope + }, + "LANG": "us", + "OPTIONS": [] + })); + return HomePage.fromPrivateJson(data['results']); + } + + //Add playlist to library + Future addPlaylist(String id) async { + await callApi('playlist.addFavorite', params: { + 'parent_playlist_id': int.parse(id) + }); + } + //Remove playlist from library + Future removePlaylist(String id) async { + await callApi('playlist.deleteFavorite', params: { + 'playlist_id': int.parse(id) + }); + } + //Delete playlist + Future deletePlaylist(String id) async { + await callApi('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 callApi('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']; + } + +} + diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart new file mode 100644 index 0000000..61e754f --- /dev/null +++ b/lib/api/definitions.dart @@ -0,0 +1,676 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pointycastle/api.dart'; +import 'package:pointycastle/block/aes_fast.dart'; +import 'package:pointycastle/block/modes/ecb.dart'; +import 'package:hex/hex.dart'; +import 'package:path/path.dart' as p; +import 'package:crypto/crypto.dart' as crypto; + +import 'dart:typed_data'; +import 'dart:convert'; + +part 'definitions.g.dart'; + +@JsonSerializable() +class Track { + + String id; + String title; + Album album; + List artists; + Duration duration; + ImageDetails albumArt; + int trackNumber; + bool offline; + Lyrics lyrics; + bool favorite; + + List playbackDetails; + + Track({this.id, this.title, this.duration, this.album, this.playbackDetails, this.albumArt, + this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite}); + + String get artistString => artists.map((art) => art.name).join(', '); + String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}"; + + String getUrl(int quality) { + var md5 = crypto.md5; + int magic = 164; + List _s1 = [ + ...utf8.encode(playbackDetails[0]), + magic, + ...utf8.encode(quality.toString()), + magic, + ...utf8.encode(id), + magic, + ...utf8.encode(playbackDetails[1]) + ]; + List _s2 = [ + ...utf8.encode(HEX.encode(md5.convert(_s1).bytes)), + magic, + ..._s1, + magic + ]; + while(_s2.length%16 > 0) _s2.add(46); + String _s3 = ''; + BlockCipher cipher = ECBBlockCipher(AESFastEngine()); + cipher.init(true, KeyParameter(Uint8List.fromList('jo6aey6haid2Teih'.codeUnits))); + for (int i=0; i<_s2.length/16; i++) { + _s3 += HEX.encode(cipher.process(Uint8List.fromList(_s2.sublist(i*16, i*16+16)))); + } + return 'https://e-cdns-proxy-${playbackDetails[0][0]}.dzcdn.net/mobile/1/$_s3'; + } + + //MediaItem + MediaItem toMediaItem() => MediaItem( + title: this.title, + album: this.album.title, + artist: this.artists[0].name, + displayTitle: this.title, + displaySubtitle: this.artistString, + displayDescription: this.album.title, + artUri: this.albumArt.full, + duration: this.duration, + id: this.id, + extras: { + "playbackDetails": jsonEncode(this.playbackDetails), + "lyrics": jsonEncode(this.lyrics.toJson()), + "albumId": this.album.id, + "artists": jsonEncode(this.artists.map((art) => art.toJson()).toList()) + } + ); + + factory Track.fromMediaItem(MediaItem mi) { + //Load album and artists. + //It is stored separately, to save id and other metadata + Album album = Album(title: mi.album); + List artists = [Artist(name: mi.displaySubtitle??mi.artist)]; + if (mi.extras != null) { + album.id = mi.extras['albumId']; + if (mi.extras['artists'] != null) { + artists = jsonDecode(mi.extras['artists']).map((j) => Artist.fromJson(j)).toList(); + } + } + return Track( + title: mi.title??mi.displayTitle, + artists: artists, + album: album, + id: mi.id, + albumArt: ImageDetails(fullUrl: mi.artUri), + duration: mi.duration, + playbackDetails: null, // So it gets updated from api + lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}")) + ); + } + + //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'], + 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']], + lyrics: Lyrics(id: json['LYRICS_ID'].toString()), + favorite: favorite + ); + } + 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??0)?1:0 + }; + factory Track.fromSQL(Map data) => Track( + id: data['trackId']??data['id'], //If loading from downloads table + title: data['title'], + album: Album(id: data['album']), + 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: Lyrics.fromJson(jsonDecode(data['lyrics'])), + favorite: (data['favorite'] == 1) ? true:false + ); + + factory Track.fromJson(Map json) => _$TrackFromJson(json); + Map toJson() => _$TrackToJson(this); +} + +@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; + + Album({this.id, this.title, this.art, this.artists, this.tracks, this.fans, this.offline, this.library}); + + 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}) => Album( + id: json['ALB_ID'], + title: json['ALB_TITLE'], + art: ImageDetails.fromPrivateString(json['ALB_PICTURE']), + artists: (json['ARTISTS']??[json]).map((dynamic art) => Artist.fromPrivateJson(art)).toList(), + tracks: (songsJson['data']??[]).map((dynamic track) => Track.fromPrivateJson(track)).toList(), + fans: json['NB_FAN'], + library: library + ); + 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 + }; + 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 + ); + + factory Album.fromJson(Map json) => _$AlbumFromJson(json); + Map toJson() => _$AlbumToJson(this); +} + +@JsonSerializable() +class Artist { + String id; + String name; + List albums; + int albumCount; + List topTracks; + ImageDetails picture; + int fans; + bool offline; + bool library; + + Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library}); + + String get fansString => NumberFormat.compact().format(fans); + + //JSON + factory Artist.fromPrivateJson( + Map json, { + Map albumsJson = const {}, + Map topJson = const {}, + bool library = false + }) => Artist( + id: json['ART_ID'], + name: json['ART_NAME'], + fans: json['NB_FAN'], + picture: 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 + ); + 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': this.albumCount??(this.albums??[]).length, + 'offline': off?1:0, + 'library': (library??false)?1:0 + }; + 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] + )), + albumCount: data['albumCount'], + picture: ImageDetails(fullUrl: data['picture']), + fans: data['fans'], + offline: (data['offline'] == 1)?true:false, + library: (data['library'] == 1)?true:false + ); + + 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'], + title: json['TITLE'], + trackCount: json['NB_SONG']??songsJson['total'], + image: ImageDetails.fromPrivateString(json['PLAYLIST_PICTURE'], type: 'playlist'), + 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, (i) => Track( + id: data['tracks'].split(',')[i] + )), + image: ImageDetails(fullUrl: data['image']), + duration: Duration(seconds: data['duration']), + 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; + + ImageDetails({this.fullUrl, this.thumbUrl}); + + //Get full/thumb with fallback + String get full => fullUrl??thumbUrl; + String get thumb => thumbUrl??fullUrl; + + //JSON + factory ImageDetails.fromPrivateString(String art, {String type='cover'}) => ImageDetails( + fullUrl: 'https://e-cdns-images.dzcdn.net/images/$type/$art/1400x1400-000000-80-0-0.jpg', + thumbUrl: 'https://e-cdns-images.dzcdn.net/images/$type/$art/180x180-000000-80-0-0.jpg' + ); + factory ImageDetails.fromPrivateJson(Map json) => ImageDetails.fromPrivateString( + json['MD5'].split('-').first, + type: json['TYPE'] + ); + + factory ImageDetails.fromJson(Map json) => _$ImageDetailsFromJson(json); + Map toJson() => _$ImageDetailsToJson(this); +} + +class SearchResults { + List tracks; + List albums; + List artists; + List playlists; + + SearchResults({this.tracks, this.albums, this.artists, this.playlists}); + + //Check if no search results + bool get empty { + return ((tracks == null || tracks.length == 0) && + (albums == null || albums.length == 0) && + (artists == null || artists.length == 0) && + (playlists == null || playlists.length == 0)); + } + + 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() + ); +} + +@JsonSerializable() +class Lyrics { + String id; + String writers; + List lyrics; + + Lyrics({this.id, this.writers, this.lyrics}); + + Lyrics get error => Lyrics( + id: id, + writers: writers, + lyrics: [Lyric( + offset: Duration(milliseconds: 0), + text: 'Error loading lyrics!' + )] + ); + + //JSON + factory Lyrics.fromPrivateJson(Map json) { + Lyrics l = Lyrics( + id: json['LYRICS_ID'], + writers: json['LYRICS_WRITERS'], + lyrics: json['LYRICS_SYNC_JSON'].map((l) => Lyric.fromPrivateJson(l)).toList() + ); + //Clean empty lyrics + l.lyrics.removeWhere((l) => l.offset == null); + return l; + } + + factory Lyrics.fromJson(Map json) => _$LyricsFromJson(json); + Map toJson() => _$LyricsToJson(this); +} + +@JsonSerializable() +class Lyric { + Duration offset; + String text; + + Lyric({this.offset, this.text}); + + //JSON + factory Lyric.fromPrivateJson(Map json) { + if (json['milliseconds'] == null || json['line'] == null) return Lyric(); //Empty lyric + return Lyric( + offset: Duration(milliseconds: int.parse(json['milliseconds'].toString())), + text: json['line'] + ); + } + + factory Lyric.fromJson(Map json) => _$LyricFromJson(json); + Map toJson() => _$LyricToJson(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; + + SmartTrackList({this.id, this.title, this.description, this.trackCount, this.tracks, this.cover, this.subtitle}); + + //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}); + + //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(this.toJson())); + } + Future load() async { + String path = await _getPath(); + Map data = jsonDecode(await File(path).readAsString()); + return HomePage.fromJson(data); + } + + //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; + + @JsonKey(fromJson: _homePageItemFromJson, toJson: _homePageItemToJson) + List items; + + HomePageSection({this.layout, this.items, this.title}); + + //JSON + factory HomePageSection.fromPrivateJson(Map json) { + HomePageSection hps = HomePageSection(title: json['title'], items: []); + String layout = json['layout']; + //No ads there + if (layout == 'ads') return null; + if (layout == 'horizontal-grid' || layout == 'grid') { + hps.layout = HomePageSectionLayout.ROW; + } else { + //Currently only row layout + return null; + } + //Parse items + for (var i in (json['items']??[])) { + HomePageItem hpi = HomePageItem.fromPrivateJson(i); + if (hpi != null) 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}); + + factory HomePageItem.fromPrivateJson(Map json) { + String type = json['type']; + switch (type) { + //Smart Track List + case 'flow': + case 'smarttracklist': + 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'])); + default: + return null; + } + } + + factory HomePageItem.fromJson(Map json) { + String _t = json['type']; + switch (_t) { + 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'])); + 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; + @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) + Color backgroundColor; + + DeezerChannel({this.id, this.title, this.backgroundColor, this.target}); + + factory DeezerChannel.fromPrivateJson(Map json) => DeezerChannel( + id: json['id'], + title: json['title'], + backgroundColor: Color(int.parse(json['background_color'].replaceFirst('#', 'FF'), radix: 16)), + target: json['target'].replaceFirst('/', '') + ); + + //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); +} + +enum HomePageItemType { + SMARTTRACKLIST, + PLAYLIST, + ARTIST, + CHANNEL, + ALBUM +} + +enum HomePageSectionLayout { + ROW +} + +enum RepeatType { + NONE, + LIST, + TRACK +} \ No newline at end of file diff --git a/lib/api/definitions.g.dart b/lib/api/definitions.g.dart new file mode 100644 index 0000000..8d840b8 --- /dev/null +++ b/lib/api/definitions.g.dart @@ -0,0 +1,338 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'definitions.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Track _$TrackFromJson(Map json) { + return Track( + id: json['id'] as String, + title: json['title'] as String, + duration: json['duration'] == null + ? null + : Duration(microseconds: json['duration'] as int), + 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) => + e == null ? null : Artist.fromJson(e as Map)) + ?.toList(), + trackNumber: json['trackNumber'] as int, + offline: json['offline'] as bool, + lyrics: json['lyrics'] == null + ? null + : Lyrics.fromJson(json['lyrics'] as Map), + favorite: json['favorite'] as bool, + ); +} + +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, + 'playbackDetails': instance.playbackDetails, + }; + +Album _$AlbumFromJson(Map json) { + return 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) => + e == null ? null : Artist.fromJson(e as Map)) + ?.toList(), + tracks: (json['tracks'] as List) + ?.map( + (e) => e == null ? null : Track.fromJson(e as Map)) + ?.toList(), + fans: json['fans'] as int, + offline: json['offline'] as bool, + library: json['library'] as bool, + ); +} + +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, + }; + +Artist _$ArtistFromJson(Map json) { + return Artist( + id: json['id'] as String, + name: json['name'] as String, + albums: (json['albums'] as List) + ?.map( + (e) => e == null ? null : Album.fromJson(e as Map)) + ?.toList(), + albumCount: json['albumCount'] as int, + topTracks: (json['topTracks'] as List) + ?.map( + (e) => e == null ? null : Track.fromJson(e as Map)) + ?.toList(), + picture: json['picture'] == null + ? null + : ImageDetails.fromJson(json['picture'] as Map), + fans: json['fans'] as int, + offline: json['offline'] as bool, + library: json['library'] as bool, + ); +} + +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, + }; + +Playlist _$PlaylistFromJson(Map json) { + return Playlist( + id: json['id'] as String, + title: json['title'] as String, + tracks: (json['tracks'] as List) + ?.map( + (e) => e == null ? null : Track.fromJson(e as Map)) + ?.toList(), + image: json['image'] == null + ? null + : ImageDetails.fromJson(json['image'] as Map), + trackCount: json['trackCount'] as int, + duration: json['duration'] == null + ? null + : Duration(microseconds: json['duration'] as int), + user: json['user'] == null + ? null + : User.fromJson(json['user'] as Map), + fans: json['fans'] as int, + 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) { + return 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) { + return ImageDetails( + fullUrl: json['fullUrl'] as String, + thumbUrl: json['thumbUrl'] as String, + ); +} + +Map _$ImageDetailsToJson(ImageDetails instance) => + { + 'fullUrl': instance.fullUrl, + 'thumbUrl': instance.thumbUrl, + }; + +Lyrics _$LyricsFromJson(Map json) { + return Lyrics( + id: json['id'] as String, + writers: json['writers'] as String, + lyrics: (json['lyrics'] as List) + ?.map( + (e) => e == null ? null : Lyric.fromJson(e as Map)) + ?.toList(), + ); +} + +Map _$LyricsToJson(Lyrics instance) => { + 'id': instance.id, + 'writers': instance.writers, + 'lyrics': instance.lyrics, + }; + +Lyric _$LyricFromJson(Map json) { + return Lyric( + offset: json['offset'] == null + ? null + : Duration(microseconds: json['offset'] as int), + text: json['text'] as String, + ); +} + +Map _$LyricToJson(Lyric instance) => { + 'offset': instance.offset?.inMicroseconds, + 'text': instance.text, + }; + +QueueSource _$QueueSourceFromJson(Map json) { + return 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) { + return SmartTrackList( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + trackCount: json['trackCount'] as int, + tracks: (json['tracks'] as List) + ?.map( + (e) => e == null ? null : Track.fromJson(e as Map)) + ?.toList(), + cover: json['cover'] == null + ? null + : ImageDetails.fromJson(json['cover'] as Map), + subtitle: json['subtitle'] 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, + }; + +HomePage _$HomePageFromJson(Map json) { + return HomePage( + sections: (json['sections'] as List) + ?.map((e) => e == null + ? null + : HomePageSection.fromJson(e as Map)) + ?.toList(), + ); +} + +Map _$HomePageToJson(HomePage instance) => { + 'sections': instance.sections, + }; + +HomePageSection _$HomePageSectionFromJson(Map json) { + return HomePageSection( + layout: + _$enumDecodeNullable(_$HomePageSectionLayoutEnumMap, json['layout']), + items: HomePageSection._homePageItemFromJson(json['items']), + title: json['title'] as String, + ); +} + +Map _$HomePageSectionToJson(HomePageSection instance) => + { + 'title': instance.title, + 'layout': _$HomePageSectionLayoutEnumMap[instance.layout], + 'items': HomePageSection._homePageItemToJson(instance.items), + }; + +T _$enumDecode( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + throw ArgumentError('A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}'); + } + + final value = enumValues.entries + .singleWhere((e) => e.value == source, orElse: () => null) + ?.key; + + if (value == null && unknownValue == null) { + throw ArgumentError('`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}'); + } + return value ?? unknownValue; +} + +T _$enumDecodeNullable( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + return null; + } + return _$enumDecode(enumValues, source, unknownValue: unknownValue); +} + +const _$HomePageSectionLayoutEnumMap = { + HomePageSectionLayout.ROW: 'ROW', +}; + +DeezerChannel _$DeezerChannelFromJson(Map json) { + return DeezerChannel( + id: json['id'] as String, + title: json['title'] as String, + backgroundColor: + DeezerChannel._colorFromJson(json['backgroundColor'] as int), + target: json['target'] as String, + ); +} + +Map _$DeezerChannelToJson(DeezerChannel instance) => + { + 'id': instance.id, + 'target': instance.target, + 'title': instance.title, + 'backgroundColor': DeezerChannel._colorToJson(instance.backgroundColor), + }; diff --git a/lib/api/download.dart b/lib/api/download.dart new file mode 100644 index 0000000..bd7b7f5 --- /dev/null +++ b/lib/api/download.dart @@ -0,0 +1,577 @@ +import 'package:disk_space/disk_space.dart'; +import 'package:ext_storage/ext_storage.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:random_string/random_string.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as p; +import 'package:dio/dio.dart'; +import 'package:filesize/filesize.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import 'dart:io'; + +import 'dart:async'; +import 'deezer.dart'; +import '../settings.dart'; +import 'definitions.dart'; +import '../ui/cached_image.dart'; + +DownloadManager downloadManager = DownloadManager(); +MethodChannel platformChannel = const MethodChannel('f.f.freezer/native'); + +class DownloadManager { + + Database db; + List queue = []; + String _offlinePath; + Future _download; + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; + bool _cancelNotifications = true; + + bool get stopped => queue.length > 0 && _download == null; + + Future init() async { + //Prepare DB + String dir = await getDatabasesPath(); + String path = p.join(dir, 'offline.db'); + db = await openDatabase( + path, + version: 1, + onCreate: (Database db, int version) async { + Batch b = db.batch(); + //Create tables + b.execute(""" CREATE TABLE downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, url TEXT, private INTEGER, state INTEGER, trackId TEXT)"""); + b.execute("""CREATE TABLE tracks ( + id TEXT PRIMARY KEY, title TEXT, album TEXT, artists TEXT, duration INTEGER, albumArt TEXT, trackNumber INTEGER, offline INTEGER, lyrics TEXT, favorite INTEGER)"""); + b.execute("""CREATE TABLE albums ( + id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER)"""); + b.execute("""CREATE TABLE artists ( + id TEXT PRIMARY KEY, name TEXT, albums TEXT, topTracks TEXT, picture TEXT, fans INTEGER, albumCount INTEGER, offline INTEGER, library INTEGER)"""); + b.execute("""CREATE TABLE playlists ( + id TEXT PRIMARY KEY, title TEXT, tracks TEXT, image TEXT, duration INTEGER, userId TEXT, userName TEXT, fans INTEGER, library INTEGER, description TEXT)"""); + await b.commit(); + } + ); + //Prepare folders (/sdcard/Android/data/freezer/data/) + _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/'); + await Directory(_offlinePath).create(recursive: true); + + //Notifications + await _prepareNotifications(); + + //Restore + List downloads = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 0"); + downloads.forEach((download) => queue.add(Download.fromSQL(download, parseTrack: true))); + } + + //Initialize flutter local notification plugin + Future _prepareNotifications() async { + flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + AndroidInitializationSettings androidInitializationSettings = AndroidInitializationSettings('@drawable/ic_logo'); + InitializationSettings initializationSettings = InitializationSettings(androidInitializationSettings, null); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + } + + //Show download progress notification, if now/total = null, show intermediate + Future _startProgressNotification() async { + _cancelNotifications = false; + Timer.periodic(Duration(milliseconds: 500), (timer) async { + //Cancel notifications + if (_cancelNotifications) { + flutterLocalNotificationsPlugin.cancel(10); + timer.cancel(); + return; + } + //Not downloading + if (this.queue.length <= 0) return; + Download d = queue[0]; + //Prepare and show notification + AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( + 'download', 'Download', 'Download', + importance: Importance.Default, + priority: Priority.Default, + showProgress: true, + maxProgress: d.total??1, + progress: d.received??1, + playSound: false, + enableVibration: false, + autoCancel: true, + //ongoing: true, //Allow dismissing + indeterminate: (d.total == null || d.total == d.received), + onlyAlertOnce: true + ); + NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null); + await downloadManager.flutterLocalNotificationsPlugin.show( + 10, + 'Downloading: ${d.track.title}', + (d.state == DownloadState.POST) ? 'Post processing...' : '${filesize(d.received)} / ${filesize(d.total)} (${queue.length} in queue)', + notificationDetails + ); + }); + } + + //Update queue, start new download + void updateQueue() async { + if (_download == null && queue.length > 0) { + _download = queue[0].download( + onDone: () async { + //On download finished + await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]); + /* + if (queue[0].private) { + await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]); + } else { + //Remove on db if public + await db.delete('downloads', where: 'trackId = ?', whereArgs: [queue[0].track.id]); + } + */ + queue.removeAt(0); + _download = null; + //Remove notification if no more downloads + if (queue.length == 0) { + _cancelNotifications = true; + } + updateQueue(); + } + ).catchError((err) async { + //Catch download errors + _download = null; + _cancelNotifications = true; + await _showError(); + }); + //Show download progress notifications + if (_cancelNotifications == null || _cancelNotifications) _startProgressNotification(); + } + } + + //Show error notification + Future _showError() async { + AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( + 'downloadError', 'Download Error', 'Download Error' + ); + NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null); + flutterLocalNotificationsPlugin.show( + 11, 'Error while downloading!', 'Please restart downloads in the library', notificationDetails + ); + } + + //Returns all offline tracks + Future> allOfflineTracks() async { + List data = await db.query('tracks', where: 'offline == 1'); + List tracks = []; + //Load track data + for (var t in data) { + tracks.add(await getTrack(t['id'])); + } + return tracks; + } + + //Get all offline playlists + Future> getOfflinePlaylists() async { + List data = await db.query('playlists'); + List playlists = []; + //Load playlists + for (var p in data) { + playlists.add(await getPlaylist(p['id'])); + } + return playlists; + } + + //Get playlist metadata with tracks + Future getPlaylist(String id) async { + if (id == null) return null; + List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]); + if (data.length == 0) return null; + //Load playlist tracks + Playlist p = Playlist.fromSQL(data[0]); + for (int i=0; i getFavorites() async { + return await getPlaylist('FAVORITES'); + } + + Future> getOfflineAlbums({List albumsData}) async { + //Load albums + if (albumsData == null) { + albumsData = await db.query('albums', where: 'offline == 1'); + } + List albums = albumsData.map((alb) => Album.fromSQL(alb)).toList(); + for(int i=0; i((a) => Artist.fromSQL(a)).toList(); + } + return albums; + } + + //Get track with metadata from db + Future getTrack(String id, {Album album, List artists}) async { + List tracks = await db.query('tracks', where: 'id == ?', whereArgs: [id]); + if (tracks.length == 0) return null; + Track t = Track.fromSQL(tracks[0]); + //Load album from DB + t.album = album ?? Album.fromSQL((await db.query('albums', where: 'id == ?', whereArgs: [t.album.id]))[0]); + if (artists != null) { + t.artists = artists; + return t; + } + //Load artists from DB + for (int i=0; i 0) return; + //and in playlists + counter = await db.rawQuery('SELECT COUNT(*) FROM playlists WHERE tracks LIKE "%$id%"'); + if (counter[0]['COUNT(*)'] > 0) return; + //Remove file + List download = await db.query('downloads', where: 'trackId == ?', whereArgs: [id]); + await File(download[0]['path']).delete(); + //Delete from db + await db.delete('tracks', where: 'id == ?', whereArgs: [id]); + await db.delete('downloads', where: 'trackId == ?', whereArgs: [id]); + } + + //Delete offline album + Future removeOfflineAlbum(String id) async { + List data = await db.rawQuery('SELECT * FROM albums WHERE id == ? AND offline == 1', [id]); + if (data.length == 0) return; + Map album = Map.from(data[0]); //make writable + //Remove DB + album['offline'] = 0; + await db.update('albums', album, where: 'id == ?', whereArgs: [id]); + //Get track ids + List tracks = album['tracks'].split(','); + for (String t in tracks) { + //Remove tracks + await removeOfflineTrack(t); + } + } + + Future removeOfflinePlaylist(String id) async { + List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]); + if (data.length == 0) return; + Playlist p = Playlist.fromSQL(data[0]); + //Remove db + await db.delete('playlists', where: 'id == ?', whereArgs: [id]); + //Remove tracks + for(Track t in p.tracks) { + await removeOfflineTrack(t.id); + } + } + + //Get path to offline track + Future getOfflineTrackPath(String id) async { + List tracks = await db.rawQuery('SELECT path FROM downloads WHERE state == 1 AND trackId == ?', [id]); + if (tracks.length < 1) { + return null; + } + Download d = Download.fromSQL(tracks[0]); + return d.path; + } + + Future addOfflineTrack(Track track, {private = true}) async { + //Paths + String path = p.join(_offlinePath, track.id); + if (track.playbackDetails == null) { + //Get track from API if download info missing + track = await deezerAPI.track(track.id); + } + //Load lyrics + try { + Lyrics l = await deezerAPI.lyrics(track.id); + track.lyrics = l; + } catch (e) {} + + String url = track.getUrl(settings.getQualityInt(settings.offlineQuality)); + if (!private) { + //Check permissions + if (!(await Permission.storage.request().isGranted)) { + return; + } + //If saving to external + url = track.getUrl(settings.getQualityInt(settings.downloadQuality)); + //Save just extension to path, will be generated before download + path = 'mp3'; + if (settings.downloadQuality == AudioQuality.FLAC) { + path = 'flac'; + } + } + + Download download = Download(track: track, path: path, url: url, private: private); + //Database + Batch b = db.batch(); + b.insert('downloads', download.toSQL()); + b.insert('tracks', track.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore); + + if (private) { + //Duplicate check + List duplicate = await db.rawQuery('SELECT * FROM downloads WHERE trackId == ?', [track.id]); + if (duplicate.length != 0) return; + //Save art + await imagesDatabase.getImage(track.albumArt.full, permanent: true); + //Save to db + b.insert('tracks', track.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace); + b.insert('albums', track.album.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore); + track.artists.forEach((art) => b.insert('artists', art.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore)); + } + await b.commit(); + + queue.add(download); + updateQueue(); + } + + Future addOfflineAlbum(Album album, {private = true}) async { + //Get full album from API if tracks are missing + if (album.tracks == null || album.tracks.length == 0) { + album = await deezerAPI.album(album.id); + } + //Update album in database + if (private) { + await db.insert('albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace); + } + //Save all tracks + for (Track track in album.tracks) { + await addOfflineTrack(track, private: private); + } + } + + //Add offline playlist, can be also used as update + Future addOfflinePlaylist(Playlist playlist, {private = true}) async { + //Load full playlist if missing tracks + if (playlist.tracks == null || playlist.tracks.length != playlist.trackCount) { + playlist = await deezerAPI.fullPlaylist(playlist.id); + } + playlist.library = true; + //To DB + if (private) { + await db.insert('playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace); + } + //Download all tracks + for (Track t in playlist.tracks) { + await addOfflineTrack(t, private: private); + } + } + + + Future checkOffline({Album album, Track track, Playlist playlist}) async { + //Check if album/track (TODO: Artist, playlist) is offline + if (track != null) { + List res = await db.query('tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]); + if (res.length == 0) return false; + return true; + } + + if (album != null) { + List res = await db.query('albums', where: 'id == ? AND offline == 1', whereArgs: [album.id]); + if (res.length == 0) return false; + return true; + } + + if (playlist != null && playlist.id != null) { + List res = await db.query('playlists', where: 'id == ?', whereArgs: [playlist.id]); + if (res.length == 0) return false; + return true; + } + return false; + } + + //Offline search + Future search(String query) async { + SearchResults results = SearchResults( + tracks: [], + albums: [], + artists: [], + playlists: [] + ); + //Tracks + List tracksData = await db.rawQuery('SELECT * FROM tracks WHERE offline == 1 AND title like "%$query%"'); + for (Map trackData in tracksData) { + results.tracks.add(await getTrack(trackData['id'])); + } + //Albums + List albumsData = await db.rawQuery('SELECT * FROM albums WHERE offline == 1 AND title like "%$query%"'); + results.albums = await getOfflineAlbums(albumsData: albumsData); + //Artists + //TODO: offline artists + //Playlists + List playlists = await db.rawQuery('SELECT * FROM playlists WHERE title like "%$query%"'); + for (Map playlist in playlists) { + results.playlists.add(await getPlaylist(playlist['id'])); + } + return results; + } + + Future> getFinishedDownloads() async { + //Fetch from db + List data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1"); + List downloads = data.map((d) => Download.fromSQL(d, parseTrack: true)).toList(); + return downloads; + } + + //Get stats for library screen + Future> getStats() async { + //Get offline counts + int trackCount = (await db.rawQuery('SELECT COUNT(*) FROM tracks WHERE offline == 1'))[0]['COUNT(*)']; + int albumCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)']; + int playlistCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)']; + //Free space + double diskSpace = await DiskSpace.getFreeDiskSpace; + + //Used space + List offlineStat = await Directory(_offlinePath).list().toList(); + int offlineSize = 0; + for (var fs in offlineStat) { + offlineSize += (await fs.stat()).size; + } + + //Return as a list, maybe refactor in future if feature stays + return ([ + trackCount.toString(), + albumCount.toString(), + playlistCount.toString(), + filesize(offlineSize), + filesize((diskSpace * 1000000).floor()) + ]); + } + + +} + +class Download { + Track track; + String path; + String url; + bool private; + DownloadState state; + String _cover; + + int received = 0; + int total = 1; + + Download({this.track, this.path, this.url, this.private, this.state = DownloadState.NONE}); + + Future download({onDone}) async { + Dio dio = Dio(); + + //TODO: Check for internet before downloading + + if (!this.private) { + String ext = this.path; + //Get track details + this.track = await deezerAPI.track(track.id); + //Get path if public + RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]'); + //Download path + if (settings.downloadFolderStructure) { + this.path = p.join( + settings.downloadPath ?? (await ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC)), + track.artists[0].name.replaceAll(sanitize, ''), + track.album.title.replaceAll(sanitize, ''), + ); + } else { + this.path = settings.downloadPath; + } + //Make dirs + await Directory(this.path).create(recursive: true); + + //Grab cover + _cover = p.join(this.path, 'cover.jpg'); + if (!settings.downloadFolderStructure) _cover = p.join(this.path, randomAlpha(12) + '_cover.jpg'); + + if (!await File(_cover).exists()) { + try { + await dio.download( + this.track.albumArt.full, + _cover, + ); + } catch (e) {print('Error downloading cover');} + } + + //Add filename + String _filename = '${track.trackNumber.toString().padLeft(2, '0')}. ${track.title.replaceAll(sanitize, "")}.$ext'; + //Different naming types + if (settings.downloadNaming == DownloadNaming.STANDALONE) + _filename = '${track.artistString.replaceAll(sanitize, "")} - ${track.title.replaceAll(sanitize, "")}.$ext'; + + this.path = p.join(this.path, _filename); + } + //Download + this.state = DownloadState.DOWNLOADING; + + await dio.download( + this.url, + this.path + '.ENC', + deleteOnError: true, + onReceiveProgress: (rec, total) { + this.received = rec; + this.total = total; + } + ); + + this.state = DownloadState.POST; + //Decrypt + await platformChannel.invokeMethod('decryptTrack', {'id': track.id, 'path': path}); + //Tag + if (!private) { + //Tag track in native + await platformChannel.invokeMethod('tagTrack', { + 'path': path, + 'title': track.title, + 'album': track.album.title, + 'artists': track.artistString, + 'artist': track.artists[0].name, + 'cover': _cover, + 'trackNumber': track.trackNumber + }); + } + //Remove encrypted + await File(path + '.ENC').delete(); + if (!settings.downloadFolderStructure) await File(_cover).delete(); + this.state = DownloadState.DONE; + onDone(); + return; + } + + //JSON + Map toSQL() => { + 'trackId': track.id, + 'path': path, + 'url': url, + 'state': state == DownloadState.DONE ? 1:0, + 'private': private?1:0 + }; + factory Download.fromSQL(Map data, {parseTrack = false}) => Download( + track: parseTrack?Track.fromSQL(data):Track(id: data['trackId']), + path: data['path'], + url: data['url'], + state: data['state'] == 1 ? DownloadState.DONE:DownloadState.NONE, + private: data['private'] == 1 + ); +} + +enum DownloadState { + NONE, + DOWNLOADING, + POST, + DONE +} \ No newline at end of file diff --git a/lib/api/player.dart b/lib/api/player.dart new file mode 100644 index 0000000..9d9491d --- /dev/null +++ b/lib/api/player.dart @@ -0,0 +1,657 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/ui/cached_image.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:connectivity/connectivity.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'definitions.dart'; +import '../settings.dart'; + +import 'dart:io'; +import 'dart:async'; +import 'dart:convert'; + +PlayerHelper playerHelper = PlayerHelper(); + +class PlayerHelper { + + StreamSubscription _customEventSubscription; + StreamSubscription _playbackStateStreamSubscription; + QueueSource queueSource; + RepeatType repeatType = RepeatType.NONE; + + //Find queue index by id + int get queueIndex => AudioService.queue.indexWhere((mi) => mi.id == AudioService.currentMediaItem?.id??'Random string so it returns -1'); + + Future start() async { + //Subscribe to custom events + _customEventSubscription = AudioService.customEventStream.listen((event) async { + if (!(event is Map)) return; + if (event['action'] == 'onLoad') { + //After audio_service is loaded, load queue, set quality + await settings.updateAudioServiceQuality(); + await AudioService.customAction('load'); + return; + } + if (event['action'] == 'onRestore') { + //Load queueSource from isolate + this.queueSource = QueueSource.fromJson(event['queueSource']); + } + if (event['action'] == 'queueEnd') { + //If last song is played, load more queue + this.queueSource = QueueSource.fromJson(event['queueSource']); + print(queueSource.toJson()); + return; + } + }); + _playbackStateStreamSubscription = AudioService.playbackStateStream.listen((event) { + //Log song (if allowed) + if (event == null) return; + if (event.processingState == AudioProcessingState.ready && event.playing) { + if (settings.logListen) deezerAPI.logListen(AudioService.currentMediaItem.id); + } + }); + //Start audio_service + _startService(); + } + + Future _startService() async { + if (AudioService.running) return; + await AudioService.start( + backgroundTaskEntrypoint: backgroundTaskEntrypoint, + androidEnableQueue: true, + androidStopForegroundOnPause: true, + androidNotificationOngoing: false, + androidNotificationClickStartsActivity: true, + androidNotificationChannelDescription: 'Freezer', + androidNotificationChannelName: 'Freezer', + androidNotificationIcon: 'drawable/ic_logo' + ); + } + + //Repeat toggle + Future changeRepeat() async { + //Change to next repeat type + switch (repeatType) { + case RepeatType.NONE: + repeatType = RepeatType.LIST; break; + case RepeatType.LIST: + repeatType = RepeatType.TRACK; break; + default: + repeatType = RepeatType.NONE; break; + } + //Set repeat type + await AudioService.customAction("repeatType", RepeatType.values.indexOf(repeatType)); + } + + //Executed before exit + Future onExit() async { + _customEventSubscription.cancel(); + _playbackStateStreamSubscription.cancel(); + } + + //Replace queue, play specified track id + Future _loadQueuePlay(List queue, String trackId) async { + await _startService(); + await settings.updateAudioServiceQuality(); + await AudioService.updateQueue(queue); + await AudioService.playFromMediaId(trackId); + } + + //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 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' + )); + } + //Load tracks as queue, play track id, set queue source + Future playFromTrackList(List tracks, String trackId, QueueSource queueSource) async { + await _startService(); + + List queue = tracks.map((track) => track.toMediaItem()).toList(); + await setQueueSource(queueSource); + await _loadQueuePlay(queue, trackId); + } + + //Load smart track list as queue, start from beginning + Future playFromSmartTrackList(SmartTrackList stl) async { + //Load from API if no tracks + if (stl.tracks == null || stl.tracks.length == 0) { + if (settings.offlineMode) { + Fluttertoast.showToast( + msg: "Offline mode, can't play flow/smart track lists.", + 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(); + } else { + stl = await deezerAPI.smartTrackList(stl.id); + } + } + QueueSource queueSource = QueueSource( + id: stl.id, + source: (stl.id == 'flow')?'flow':'smarttracklist', + text: stl.title + ); + await playFromTrackList(stl.tracks, stl.tracks[0].id, queueSource); + } + + Future setQueueSource(QueueSource queueSource) async { + await _startService(); + + this.queueSource = queueSource; + await AudioService.customAction('queueSource', queueSource.toJson()); + } + +} + +void backgroundTaskEntrypoint() async { + AudioServiceBackground.run(() => AudioPlayerTask()); +} + +class AudioPlayerTask extends BackgroundAudioTask { + + AudioPlayer _audioPlayer = AudioPlayer(); + + List _queue = []; + int _queueIndex = -1; + + bool _playing; + bool _interrupted; + AudioProcessingState _skipState; + Duration _lastPosition; + + ImagesDatabase imagesDB; + int mobileQuality; + int wifiQuality; + + StreamSubscription _eventSub; + StreamSubscription _playerStateSub; + + QueueSource queueSource; + int repeatType = 0; + + MediaItem get mediaItem => _queue[_queueIndex]; + + //Controls + final playControl = MediaControl( + androidIcon: 'drawable/ic_play_arrow', + label: 'Play', + action: MediaAction.play + ); + final pauseControl = MediaControl( + androidIcon: 'drawable/ic_pause', + label: 'Pause', + action: MediaAction.pause + ); + final stopControl = MediaControl( + androidIcon: 'drawable/ic_stop', + label: 'Stop', + action: MediaAction.stop + ); + final nextControl = MediaControl( + androidIcon: 'drawable/ic_skip_next', + label: 'Next', + action: MediaAction.skipToNext + ); + final previousControl = MediaControl( + androidIcon: 'drawable/ic_skip_previous', + label: 'Previous', + action: MediaAction.skipToPrevious + ); + + @override + Future onStart(Map params) async { + _playerStateSub = _audioPlayer.playbackStateStream + .where((state) => state == AudioPlaybackState.completed) + .listen((_event) { + if (_queue.length > _queueIndex + 1) { + onSkipToNext(); + return; + } else { + //Repeat whole list (if enabled) + if (repeatType == 1) { + _skip(-_queueIndex); + return; + } + //Ask for more tracks in queue + AudioServiceBackground.sendCustomEvent({ + 'action': 'queueEnd', + 'queueSource': (queueSource??QueueSource()).toJson() + }); + if (_playing) _playing = false; + _setState(AudioProcessingState.none); + return; + } + }); + + //Read audio player events + _eventSub = _audioPlayer.playbackEventStream.listen((event) { + AudioProcessingState bufferingState = event.buffering ? AudioProcessingState.buffering : null; + switch (event.state) { + case AudioPlaybackState.paused: + case AudioPlaybackState.playing: + _setState(bufferingState ?? AudioProcessingState.ready, pos: event.position); + break; + case AudioPlaybackState.connecting: + _setState(_skipState ?? AudioProcessingState.connecting, pos: event.position); + break; + default: + break; + } + }); + + //Initialize later + //await imagesDB.init(); + + AudioServiceBackground.setQueue(_queue); + AudioServiceBackground.sendCustomEvent({'action': 'onLoad'}); + } + + @override + Future onSkipToNext() { + //If repeating allowed + if (repeatType == 2) { + _skip(0); + return null; + } + _skip(1); + } + + @override + Future onSkipToPrevious() => _skip(-1); + + Future _skip(int offset) async { + int newPos = _queueIndex + offset; + //Out of bounds + if (newPos >= _queue.length || newPos < 0) return; + //First song + if (_playing == null) { + _playing = true; + } else if (_playing) { + await _audioPlayer.stop(); + } + //Update position, album art source, queue source text + _queueIndex = newPos; + //Get uri + String uri = await _getTrackUri(mediaItem); + //Modify extras + Map extras = mediaItem.extras; + extras.addAll({"qualityString": await _getQualityString(uri, mediaItem.duration)}); + _queue[_queueIndex] = mediaItem.copyWith( + artUri: await _getArtUri(mediaItem.artUri), + extras: extras + ); + //Play + AudioServiceBackground.setMediaItem(mediaItem); + _skipState = offset > 0 ? AudioProcessingState.skippingToNext:AudioProcessingState.skippingToPrevious; + //Load + await _audioPlayer.setUrl(uri); + _skipState = null; + await _saveQueue(); + (_playing??false) ? onPlay() : _setState(AudioProcessingState.ready); + } + + @override + void onPlay() async { + //Start playing preloaded queue + if (AudioServiceBackground.state.processingState == AudioProcessingState.none && _queue.length > 0) { + if (_queueIndex < 0 || _queueIndex == null) { + await this._skip(1); + } else { + await this._skip(0); + } + //Restore position from saved queue + if (_lastPosition != null) { + onSeekTo(_lastPosition); + _lastPosition = null; + } + return; + } + if (_skipState == null) { + _playing = true; + _audioPlayer.play(); + } + } + + @override + void onPause() { + if (_skipState == null && _playing) { + _playing = false; + _audioPlayer.pause(); + } + } + + @override + void onSeekTo(Duration pos) { + _audioPlayer.seek(pos); + } + + @override + void onClick(MediaButton button) { + if (_playing) onPause(); + onPlay(); + } + + @override + Future onUpdateQueue(List q) async { + this._queue = q; + AudioServiceBackground.setQueue(_queue); + await _saveQueue(); + } + + @override + void onPlayFromMediaId(String mediaId) async { + int pos = this._queue.indexWhere((mi) => mi.id == mediaId); + await _skip(pos - _queueIndex); + if (_playing == null || !_playing) onPlay(); + } + + @override + Future onFastForward() async { + await _seekRelative(fastForwardInterval); + } + + @override + void onAddQueueItemAt(MediaItem mi, int index) { + _queue.insert(index, mi); + AudioServiceBackground.setQueue(_queue); + _saveQueue(); + } + + @override + void onAddQueueItem(MediaItem mi) { + _queue.add(mi); + AudioServiceBackground.setQueue(_queue); + _saveQueue(); + } + + @override + Future onRewind() async { + await _seekRelative(rewindInterval); + } + + Future _seekRelative(Duration offset) async { + Duration newPos = _audioPlayer.playbackEvent.position + offset; + if (newPos < Duration.zero) newPos = Duration.zero; + if (newPos > mediaItem.duration) newPos = mediaItem.duration; + onSeekTo(_audioPlayer.playbackEvent.position + offset); + } + + //Audio interruptions + @override + void onAudioFocusLost(AudioInterruption interruption) { + if (_playing) _interrupted = true; + switch (interruption) { + case AudioInterruption.pause: + case AudioInterruption.temporaryPause: + case AudioInterruption.unknownPause: + if (_playing) onPause(); + break; + case AudioInterruption.temporaryDuck: + _audioPlayer.setVolume(0.5); + break; + } + } + + @override + void onAudioFocusGained(AudioInterruption interruption) { + switch (interruption) { + case AudioInterruption.temporaryPause: + if (!_playing && _interrupted) onPlay(); + break; + case AudioInterruption.temporaryDuck: + _audioPlayer.setVolume(1.0); + break; + default: + break; + } + _interrupted = false; + } + + @override + void onAudioBecomingNoisy() { + onPause(); + } + + + @override + Future onCustomAction(String name, dynamic args) async { + if (name == 'updateQuality') { + //Pass wifi & mobile quality by custom action + //Isolate can't access globals + this.wifiQuality = args['wifiQuality']; + this.mobileQuality = args['mobileQuality']; + } + if (name == 'saveQueue') { + await this._saveQueue(); + } + //Load queue, called after start + if (name == 'load') { + await _loadQueue(); + } + //Change queue source + if (name == 'queueSource') { + this.queueSource = QueueSource.fromJson(Map.from(args)); + } + //Shuffle + if (name == 'shuffleQueue') { + MediaItem mi = mediaItem; + shuffle(this._queue); + _queueIndex = _queue.indexOf(mi); + AudioServiceBackground.setQueue(this._queue); + } + //Repeating + if (name == 'repeatType') { + this.repeatType = args; + } + return true; + } + + Future _getArtUri(String url) async { + //Load from cache + if (url.startsWith('http')) { + //Prepare db + if (imagesDB == null) { + imagesDB = ImagesDatabase(); + await imagesDB.init(); + } + + String path = await imagesDB.getImage(url); + return 'file://$path'; + } + //If file + if (url.startsWith('/')) return 'file://' + url; + return url; + } + + Future _getTrackUri(MediaItem mi) async { + String prefix = 'DEEZER|${mi.id}|'; + + //Check if song is available offline + String _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/'); + File f = File(p.join(_offlinePath, mi.id)); + if (await f.exists()) return f.path; + + //Get online url + Track t = Track( + id: mi.id, + playbackDetails: jsonDecode(mi.extras['playbackDetails']) //JSON Because of audio_service bug + ); + ConnectivityResult conn = await Connectivity().checkConnectivity(); + if (conn == ConnectivityResult.wifi) { + return prefix + t.getUrl(wifiQuality); + } + return prefix + t.getUrl(mobileQuality); + } + + Future _getQualityString(String uri, Duration duration) async { + //Get url/path + String url = uri; + List split = uri.split('|'); + if (split.length >= 3) url = split[2]; + + int size; + String format; + String source; + + //Local file + if (url.startsWith('/')) { + //Read first 4 bytes of file, get format + File f = File(url); + Stream> reader = f.openRead(0, 4); + List magic = await reader.first; + format = _magicToFormat(magic); + size = await f.length(); + source = 'Offline'; + } + + //URL + if (url.startsWith('http')) { + Dio dio = Dio(); + Response response = await dio.head(url); + size = int.parse(response.headers['Content-Length'][0]); + //Parse format + format = response.headers['Content-Type'][0]; + if (format.trim() == 'audio/mpeg') format = 'MP3'; + if (format.trim() == 'audio/flac') format = 'FLAC'; + source = 'Stream'; + } + //Calculate + int bitrate = ((size / 125) / duration.inSeconds).floor(); + return '$format ${bitrate}kbps ($source)'; + } + + //Magic number to string, source: https://en.wikipedia.org/wiki/List_of_file_signatures + String _magicToFormat(List magic) { + Function eq = const ListEquality().equals; + if (eq(magic.sublist(0, 4), [0x66, 0x4c, 0x61, 0x43])) return 'FLAC'; + //MP3 With ID3 + if (eq(magic.sublist(0, 3), [0x49, 0x44, 0x33])) return 'MP3'; + //MP3 + List m = magic.sublist(0, 2); + if (eq(m, [0xff, 0xfb]) ||eq(m, [0xff, 0xf3]) || eq(m, [0xff, 0xf2])) return 'MP3'; + //Unknown + return 'UNK'; + } + + @override + void onTaskRemoved() async { + await _saveQueue(); + onStop(); + } + + @override + Future onStop() async { + await _saveQueue(); + + if (_playing != null) _audioPlayer.stop(); + if (_playerStateSub != null) _playerStateSub.cancel(); + if (_eventSub != null) _eventSub.cancel(); + + await super.onStop(); + } + + @override + void onClose() async { + //await _saveQueue(); + //Gets saved in onStop() + await onStop(); + } + + //Update state + void _setState(AudioProcessingState state, {Duration pos}) { + AudioServiceBackground.setState( + controls: _getControls(), + systemActions: (_playing == null) ? [] : [MediaAction.seekTo], + processingState: state ?? AudioServiceBackground.state.processingState, + playing: _playing ?? false, + position: pos ?? _audioPlayer.playbackEvent.position, + bufferedPosition: pos ?? _audioPlayer.playbackEvent.position, + speed: _audioPlayer.speed + ); + } + + List _getControls() { + if (_playing == null || !_playing) { + //Paused / not-started + return [ + previousControl, + playControl, + nextControl + ]; + } + //Playing + return [ + previousControl, + pauseControl, + nextControl + ]; + } + + //Get queue saved file path + Future _getQueuePath() async { + Directory dir = await getApplicationDocumentsDirectory(); + return p.join(dir.path, 'offline.json'); + } + + //Export queue to JSON + Future _saveQueue() async { + print('save'); + File f = File(await _getQueuePath()); + await f.writeAsString(jsonEncode({ + 'index': _queueIndex, + 'queue': _queue.map>((mi) => mi.toJson()).toList(), + 'position': _audioPlayer.playbackEvent.position.inMilliseconds, + 'queueSource': (queueSource??QueueSource()).toJson(), + })); + } + + Future _loadQueue() async { + File f = File(await _getQueuePath()); + if (await f.exists()) { + Map json = jsonDecode(await f.readAsString()); + this._queue = (json['queue']??[]).map((mi) => MediaItem.fromJson(mi)).toList(); + this._queueIndex = json['index'] ?? -1; + this._lastPosition = Duration(milliseconds: json['position']??0); + this.queueSource = QueueSource.fromJson(json['queueSource']??{}); + if (_queue != null) { + AudioServiceBackground.setQueue(_queue); + AudioServiceBackground.setMediaItem(mediaItem); + //Update state to allow play button in notification + this._setState(AudioProcessingState.none, pos: _lastPosition); + } + //Send restored queue source to ui + AudioServiceBackground.sendCustomEvent({'action': 'onRestore', 'queueSource': (queueSource??QueueSource()).toJson()}); + return true; + } + } + +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..f1a65f0 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,175 @@ +import 'package:custom_navigator/custom_navigator.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:freezer/ui/library.dart'; +import 'package:freezer/ui/login_screen.dart'; +import 'package:freezer/ui/search.dart'; +import 'package:move_to_background/move_to_background.dart'; + +import 'ui/player_bar.dart'; +import 'api/deezer.dart'; +import 'settings.dart'; +import 'ui/cached_image.dart'; +import 'api/download.dart'; +import 'api/player.dart'; +import 'ui/home_screen.dart'; + +Function updateTheme; +GlobalKey mainNavigatorKey = GlobalKey(); +GlobalKey navigatorKey; + +void main() async { + + WidgetsFlutterBinding.ensureInitialized(); + + //Initialize globals + settings = await Settings().loadSettings(); + await imagesDatabase.init(); + await downloadManager.init(); + + runApp(FreezerApp()); +} + +class FreezerApp extends StatefulWidget { + @override + _FreezerAppState createState() => _FreezerAppState(); +} + +class _FreezerAppState extends State { + @override + void initState() { + //Make update theme global + updateTheme = _updateTheme; + super.initState(); + } + + void _updateTheme() { + setState(() { + settings.themeData; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'freezer', + theme: settings.themeData, + home: WillPopScope( + onWillPop: () async { + //For some reason AudioServiceWidget caused the app to freeze after 2 back button presses. "fix" + if (navigatorKey.currentState.canPop()) { + await navigatorKey.currentState.maybePop(); + return false; + } + await MoveToBackground.moveTaskToBack(); + return false; + }, + child: LoginMainWrapper(), + ), + navigatorKey: mainNavigatorKey, + ); + } +} + +//Wrapper for login and main screen. +class LoginMainWrapper extends StatefulWidget { + @override + _LoginMainWrapperState createState() => _LoginMainWrapperState(); +} + +class _LoginMainWrapperState extends State { + + @override + void initState() { + if (settings.arl != null) { + playerHelper.start(); + //Load token on background + deezerAPI.arl = settings.arl; + settings.offlineMode = true; + deezerAPI.authorize().then((b) { + if (b) setState(() => settings.offlineMode = false); + }); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (settings.arl == null) + return LoginWidget(callback: () => setState(() => {}),); + return MainScreen(); + } +} + + + +class MainScreen extends StatefulWidget { + @override + _MainScreenState createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + + List _screens = [ + HomeScreen(), + SearchScreen(), + LibraryScreen() + ]; + int _selected = 0; + + @override + void initState() { + navigatorKey = GlobalKey(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PlayerBar(), + BottomNavigationBar( + backgroundColor: Theme.of(context).bottomAppBarColor, + currentIndex: _selected, + onTap: (int s) async { + //Pop all routes until home screen + + while (navigatorKey.currentState.canPop()) { + await navigatorKey.currentState.maybePop(); + } + + await navigatorKey.currentState.maybePop(); + setState(() { + _selected = s; + }); + }, + selectedItemColor: Theme.of(context).primaryColor, + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + title: Text('Home') + ), + BottomNavigationBarItem( + icon: Icon(Icons.search), + title: Text('Search'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.library_music), + title: Text('Library') + ) + ], + ) + ], + ), + body: AudioServiceWidget( + child: CustomNavigator( + navigatorKey: navigatorKey, + home: _screens[_selected], + pageRoute: PageRoutes.materialPageRoute, + ), + ) + ); + } +} diff --git a/lib/settings.dart b/lib/settings.dart new file mode 100644 index 0000000..fd09f54 --- /dev/null +++ b/lib/settings.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:freezer/main.dart'; +import 'package:freezer/ui/cached_image.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:ext_storage/ext_storage.dart'; +import 'package:path/path.dart' as p; +import 'package:flutter/material.dart'; + +import 'dart:io'; +import 'dart:convert'; + +part 'settings.g.dart'; + +Settings settings; + +@JsonSerializable() +class Settings { + + //Account + String arl; + @JsonKey(ignore: true) + bool offlineMode = false; + + //Quality + @JsonKey(defaultValue: AudioQuality.MP3_320) + AudioQuality wifiQuality; + @JsonKey(defaultValue: AudioQuality.MP3_128) + AudioQuality mobileQuality; + @JsonKey(defaultValue: AudioQuality.FLAC) + AudioQuality offlineQuality; + @JsonKey(defaultValue: AudioQuality.FLAC) + AudioQuality downloadQuality; + + //Download options + String downloadPath; + @JsonKey(defaultValue: DownloadNaming.DEFAULT) + DownloadNaming downloadNaming; + @JsonKey(defaultValue: true) + bool downloadFolderStructure; + + //Appearance + @JsonKey(defaultValue: Themes.Light) + Themes theme; + + //Colors + @JsonKey(toJson: _colorToJson, fromJson: _colorFromJson) + Color primaryColor = Colors.blue; + + static _colorToJson(Color c) => c.value; + static _colorFromJson(int v) => Color(v??Colors.blue.value); + + @JsonKey(defaultValue: false) + bool useArtColor = false; + StreamSubscription _useArtColorSub; + + + //Deezer + @JsonKey(defaultValue: 'en') + String deezerLanguage; + @JsonKey(defaultValue: 'US') + String deezerCountry; + @JsonKey(defaultValue: false) + bool logListen; + + Settings({this.downloadPath, this.arl}); + + ThemeData get themeData { + switch (theme??Themes.Light) { + case Themes.Light: + return ThemeData( + fontFamily: 'Montserrat', + primaryColor: primaryColor, + accentColor: primaryColor, + sliderTheme: _sliderTheme, + toggleableActiveColor: primaryColor, + ); + case Themes.Dark: + return ThemeData( + fontFamily: 'Montserrat', + brightness: Brightness.dark, + primaryColor: primaryColor, + accentColor: primaryColor, + sliderTheme: _sliderTheme, + toggleableActiveColor: primaryColor, + ); + case Themes.Black: + return ThemeData( + fontFamily: 'Montserrat', + brightness: Brightness.dark, + primaryColor: primaryColor, + accentColor: primaryColor, + backgroundColor: Colors.black, + scaffoldBackgroundColor: Colors.black, + bottomAppBarColor: Colors.black, + dialogBackgroundColor: Colors.black, + sliderTheme: _sliderTheme, + toggleableActiveColor: primaryColor, + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: Colors.black + ) + ); + } + return ThemeData(); + } + + + void updateUseArtColor(bool v) { + useArtColor = v; + if (v) { + //On media item change set color + _useArtColorSub = AudioService.currentMediaItemStream.listen((event) async { + if (event == null || event.artUri == null) return; + this.primaryColor = await imagesDatabase.getPrimaryColor(event.artUri); + updateTheme(); + }); + } else { + //Cancel stream subscription + if (_useArtColorSub != null) { + _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 ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC)); + s.save(); + return s; + } + + Future save() async { + File f = File(await getPath()); + await f.writeAsString(jsonEncode(this.toJson())); + } + + Future updateAudioServiceQuality() async { + //Send wifi & mobile quality to audio service isolate + await AudioService.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; + } + return 8; //default + } + + 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 +} + +enum Themes { + Light, + Dark, + Black +} + +enum DownloadNaming { + DEFAULT, + STANDALONE +} \ No newline at end of file diff --git a/lib/settings.g.dart b/lib/settings.g.dart new file mode 100644 index 0000000..6d7ca97 --- /dev/null +++ b/lib/settings.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'settings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Settings _$SettingsFromJson(Map json) { + return Settings( + downloadPath: json['downloadPath'] as String, + arl: json['arl'] as String, + ) + ..wifiQuality = + _$enumDecodeNullable(_$AudioQualityEnumMap, json['wifiQuality']) ?? + AudioQuality.MP3_320 + ..mobileQuality = + _$enumDecodeNullable(_$AudioQualityEnumMap, json['mobileQuality']) ?? + AudioQuality.MP3_128 + ..offlineQuality = + _$enumDecodeNullable(_$AudioQualityEnumMap, json['offlineQuality']) ?? + AudioQuality.FLAC + ..downloadQuality = + _$enumDecodeNullable(_$AudioQualityEnumMap, json['downloadQuality']) ?? + AudioQuality.FLAC + ..downloadNaming = + _$enumDecodeNullable(_$DownloadNamingEnumMap, json['downloadNaming']) ?? + DownloadNaming.DEFAULT + ..downloadFolderStructure = json['downloadFolderStructure'] as bool ?? true + ..theme = + _$enumDecodeNullable(_$ThemesEnumMap, json['theme']) ?? Themes.Light + ..primaryColor = Settings._colorFromJson(json['primaryColor'] as int) + ..useArtColor = json['useArtColor'] as bool ?? false + ..deezerLanguage = json['deezerLanguage'] as String ?? 'en' + ..deezerCountry = json['deezerCountry'] as String ?? 'US' + ..logListen = json['logListen'] as bool ?? false; +} + +Map _$SettingsToJson(Settings instance) => { + 'arl': instance.arl, + 'wifiQuality': _$AudioQualityEnumMap[instance.wifiQuality], + 'mobileQuality': _$AudioQualityEnumMap[instance.mobileQuality], + 'offlineQuality': _$AudioQualityEnumMap[instance.offlineQuality], + 'downloadQuality': _$AudioQualityEnumMap[instance.downloadQuality], + 'downloadPath': instance.downloadPath, + 'downloadNaming': _$DownloadNamingEnumMap[instance.downloadNaming], + 'downloadFolderStructure': instance.downloadFolderStructure, + 'theme': _$ThemesEnumMap[instance.theme], + 'primaryColor': Settings._colorToJson(instance.primaryColor), + 'useArtColor': instance.useArtColor, + 'deezerLanguage': instance.deezerLanguage, + 'deezerCountry': instance.deezerCountry, + 'logListen': instance.logListen, + }; + +T _$enumDecode( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + throw ArgumentError('A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}'); + } + + final value = enumValues.entries + .singleWhere((e) => e.value == source, orElse: () => null) + ?.key; + + if (value == null && unknownValue == null) { + throw ArgumentError('`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}'); + } + return value ?? unknownValue; +} + +T _$enumDecodeNullable( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + return null; + } + return _$enumDecode(enumValues, source, unknownValue: unknownValue); +} + +const _$AudioQualityEnumMap = { + AudioQuality.MP3_128: 'MP3_128', + AudioQuality.MP3_320: 'MP3_320', + AudioQuality.FLAC: 'FLAC', +}; + +const _$DownloadNamingEnumMap = { + DownloadNaming.DEFAULT: 'DEFAULT', + DownloadNaming.STANDALONE: 'STANDALONE', +}; + +const _$ThemesEnumMap = { + Themes.Light: 'Light', + Themes.Dark: 'Dark', + Themes.Black: 'Black', +}; diff --git a/lib/ui/cached_image.dart b/lib/ui/cached_image.dart new file mode 100644 index 0000000..06c0159 --- /dev/null +++ b/lib/ui/cached_image.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; + +import 'dart:io'; +import 'dart:convert'; + +ImagesDatabase imagesDatabase = ImagesDatabase(); + +class ImagesDatabase { + + /* + images.db: + Table: images + Fields: + id - id + name - md5 hash of url. also filename + url - url + permanent - 0/1 - if image is cached or offline + */ + + + Database db; + String imagesPath; + + //Prepare database + Future init() async { + String dir = await getDatabasesPath(); + String path = p.join(dir, 'images.db'); + db = await openDatabase( + path, + version: 1, + singleInstance: false, + onCreate: (Database db, int version) async { + //Create table on db created + await db.execute('CREATE TABLE images (id INTEGER PRIMARY KEY, name TEXT, url TEXT, permanent INTEGER)'); + } + ); + //Prepare folders + imagesPath = p.join((await getApplicationDocumentsDirectory()).path, 'images/'); + Directory imagesDir = Directory(imagesPath); + await imagesDir.create(recursive: true); + } + + String getPath(String name) { + return p.join(imagesPath, name); + } + + //Get image url/path, cache it + Future getImage(String url, {bool permanent = false}) async { + //Already file + if (!url.startsWith('http')) { + url = url.replaceFirst('file://', ''); + if (!permanent) return url; + //Update in db to permanent + String name = p.basenameWithoutExtension(url); + await db.rawUpdate('UPDATE images SET permanent == 1 WHERE name == ?', [name]); + } + //Filename = md5 hash + String hash = md5.convert(utf8.encode(url)).toString(); + List results = await db.rawQuery('SELECT * FROM images WHERE name == ?', [hash]); + String path = getPath(hash); + if (results.length > 0) { + //Image in database + return path; + } + //Save image + Dio dio = Dio(); + try { + await dio.download(url, path); + await db.insert('images', {'url': url, 'name': hash, 'permanent': permanent?1:0}); + return path; + } catch (e) { + return null; + } + } + + Future getPaletteGenerator(String url) async { + String path = await getImage(url); + //Get image provider + ImageProvider provider = AssetImage('assets/cover.jpg'); + if (path != null) { + provider = FileImage(File(path)); + } + PaletteGenerator paletteGenerator = await PaletteGenerator.fromImageProvider(provider); + return paletteGenerator; + } + + //Get primary color from album art + Future getPrimaryColor(String url) async { + PaletteGenerator paletteGenerator = await getPaletteGenerator(url); + return paletteGenerator.colors.first; + } + + //Check if is dark + 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; + + const CachedImage({Key key, this.url, this.height, this.width, this.circular = false}): super(key: key); + + @override + _CachedImageState createState() => _CachedImageState(); +} + +class _CachedImageState extends State { + + final ImageProvider _placeholder = AssetImage('assets/cover.jpg'); + ImageProvider _image = AssetImage('assets/cover.jpg'); + double _opacity = 0.0; + bool _disposed = false; + + Future _getImage() async { + //Image already path + if (!widget.url.startsWith('http')) { + //Remove file://, if used in audio_service + if (widget.url.startsWith('/')) return FileImage(File(widget.url)); + return FileImage(File(widget.url.replaceFirst('file://', ''))); + } + //Load image from db + String path = await imagesDatabase.getImage(widget.url); + if (path == null) return _placeholder; + return FileImage(File(path)); + } + + //Load image and fade + void _load() async { + ImageProvider image = await _getImage(); + if (_disposed) return; + setState(() { + _image = image; + _opacity = 1.0; + }); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + @override + void initState() { + _load(); + super.initState(); + } + + @override + void didUpdateWidget(CachedImage oldWidget) { + _load(); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.circular ? + CircleAvatar( + radius: (widget.width??widget.height), + backgroundImage: _placeholder, + ): + Image( + image: _placeholder, + height: widget.height, + width: widget.width, + ), + AnimatedOpacity( + duration: Duration(milliseconds: 250), + opacity: _opacity, + child: widget.circular ? + CircleAvatar( + radius: (widget.width??widget.height), + backgroundImage: _image, + ): + Image( + image: _image, + height: widget.height, + width: widget.width, + ), + ) + ], + ); + } + +} + + diff --git a/lib/ui/details_screens.dart b/lib/ui/details_screens.dart new file mode 100644 index 0000000..88d0896 --- /dev/null +++ b/lib/ui/details_screens.dart @@ -0,0 +1,697 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/download.dart'; +import 'package:freezer/api/player.dart'; +import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/search.dart'; + +import '../api/definitions.dart'; +import 'player_bar.dart'; +import 'cached_image.dart'; +import 'tiles.dart'; +import 'menu.dart'; + +class AlbumDetails extends StatelessWidget { + + Album album; + + AlbumDetails(this.album); + + Future _loadAlbum() async { + //Get album from API, if doesn't have tracks + if (this.album.tracks == null || this.album.tracks.length == 0) { + this.album = await deezerAPI.album(album.id); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: FutureBuilder( + future: _loadAlbum(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + + //Wait for data + if (snapshot.connectionState != ConnectionState.done) return Center(child: CircularProgressIndicator(),); + //On error + if (snapshot.hasError) return ErrorScreen(); + + return ListView( + children: [ + //Album art, title, artists + Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(height: 8.0,), + CachedImage( + url: album.art.full, + height: 256.0, + ), + Container(height: 8,), + Text( + album.title, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: 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: 8.0,), + ], + ), + ), + //Details + Card( + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Icon(Icons.audiotrack, size: 32.0,), + Container(width: 8.0, height: 42.0,), //Height to adjust card height + Text( + album.tracks.length.toString(), + style: TextStyle(fontSize: 16.0), + ) + ], + ), + Row( + children: [ + Icon(Icons.timelapse, size: 32.0,), + Container(width: 8.0,), + Text( + album.durationString, + style: TextStyle(fontSize: 16.0), + ) + ], + ), + Row( + children: [ + Icon(Icons.people, size: 32.0,), + Container(width: 8.0,), + Text( + album.fansString, + style: TextStyle(fontSize: 16.0), + ) + ], + ), + ], + ), + ), + //Options (offline, download...) + Card( + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + FlatButton( + child: Row( + children: [ + Icon(Icons.favorite, size: 32), + Container(width: 4,), + Text('Library') + ], + ), + onPressed: () async { + await deezerAPI.addFavoriteAlbum(album.id); + Fluttertoast.showToast( + msg: 'Added to library', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM + ); + }, + ), + MakeAlbumOffline(album: album), + FlatButton( + child: Row( + children: [ + Icon(Icons.file_download, size: 32.0,), + Container(width: 4,), + Text('Download') + ], + ), + onPressed: () { + downloadManager.addOfflineAlbum(album, private: false); + }, + ) + ], + ), + ), + ...List.generate(album.tracks.length, (i) { + Track t = album.tracks[i]; + return TrackTile( + t, + onTap: () { + playerHelper.playFromAlbum(album, t.id); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t); + } + ); + }) + ], + ); + }, + ) + ); + } +} + +class MakeAlbumOffline extends StatefulWidget { + + Album album; + MakeAlbumOffline({Key key, this.album}): super(key: key); + + @override + _MakeAlbumOfflineState createState() => _MakeAlbumOfflineState(); +} + +class _MakeAlbumOfflineState extends State { + + bool _offline = false; + + @override + void 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, private: true); + setState(() { + _offline = true; + }); + return; + } + downloadManager.removeOfflineAlbum(widget.album.id); + setState(() { + _offline = false; + }); + }, + ), + Container(width: 4.0,), + Text( + 'Offline', + style: TextStyle(fontSize: 16), + ) + ], + ); + } +} + + +class ArtistDetails extends StatelessWidget { + + Artist artist; + ArtistDetails(this.artist); + + Future _loadArtist() async { + //Load artist from api if no albums + if ((this.artist.albums??[]).length == 0) { + this.artist = await deezerAPI.artist(artist.id); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: FutureBuilder( + future: _loadArtist(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + //Error / not done + if (snapshot.hasError) return ErrorScreen(); + if (snapshot.connectionState != ConnectionState.done) return Center(child: CircularProgressIndicator(),); + + return ListView( + children: [ + Card( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + CachedImage( + url: artist.picture.full, + height: 200, + ), + Container( + width: 200.0, + height: 220, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + artist.name, + overflow: TextOverflow.ellipsis, + maxLines: 4, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24.0, fontWeight: FontWeight.bold), + ), + Container( + height: 8.0, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.people, + size: 32.0, + ), + Container( + width: 8, + ), + Text( + artist.fansString, + style: TextStyle(fontSize: 16), + ), + ], + ), + Container( + height: 4.0, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.album, size: 32.0), + Container( + width: 8.0, + ), + Text( + artist.albumCount.toString(), + style: TextStyle(fontSize: 16), + ) + ], + ) + ], + ), + ), + ], + ), + ), + Container(height: 4.0,), + Card( + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + FlatButton( + child: Row( + children: [ + Icon(Icons.favorite, size: 32), + Container(width: 4,), + Text('Library') + ], + ), + onPressed: () async { + await deezerAPI.addFavoriteArtist(artist.id); + Fluttertoast.showToast( + msg: 'Added to library', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM + ); + }, + ), + ], + ), + ), + Container(height: 16.0,), + //Top tracks + Text( + 'Top Tracks', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22.0 + ), + ), + Container(height: 4.0), + ...List.generate(5, (i) { + if (artist.topTracks.length <= i) return Container(height: 0, width: 0,); + Track t = artist.topTracks[i]; + return TrackTile( + t, + onTap: () { + playerHelper.playFromTopTracks( + artist.topTracks, + t.id, + artist + ); + }, + onHold: () { + MenuSheet mi = MenuSheet(context); + mi.defaultTrackMenu(t); + }, + ); + }), + ListTile( + title: Text('Show more tracks'), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => TrackListScreen(artist.topTracks, QueueSource( + id: artist.id, + text: 'Top ${artist.name}', + source: 'topTracks' + ))) + ); + } + ), + Divider(), + //Albums + Text( + 'Top Albums', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22.0 + ), + ), + ...List.generate(artist.albums.length, (i) { + Album a = artist.albums[i]; + return AlbumTile( + a, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AlbumDetails(a)) + ); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultAlbumMenu( + a + ); + }, + ); + }) + ], + ); + }, + ), + ); + } +} + + +class PlaylistDetails extends StatefulWidget { + + Playlist playlist; + PlaylistDetails(this.playlist, {Key key}): super(key: key); + + @override + _PlaylistDetailsState createState() => _PlaylistDetailsState(); +} + +class _PlaylistDetailsState extends State { + + Playlist playlist; + bool _loading = false; + bool _error = false; + ScrollController _scrollController = ScrollController(); + + //Load tracks from api + void _load() async { + if (playlist.tracks.length < playlist.trackCount && !_loading) { + setState(() => _loading = true); + int pos = playlist.tracks.length; + //Get another page of tracks + List tracks; + try { + tracks = await deezerAPI.playlistTracksPage(playlist.id, pos); + } catch (e) { + setState(() => _error = true); + return; + } + + setState(() { + playlist.tracks.addAll(tracks); + _loading = false; + }); + } + } + + @override + void initState() { + playlist = widget.playlist; + //If scrolled past 90% load next tracks + _scrollController.addListener(() { + double off = _scrollController.position.maxScrollExtent * 0.90; + if (_scrollController.position.pixels > off) { + _load(); + } + }); + //Load if no tracks + if (playlist.tracks.length == 0) { + //Get correct metadata + deezerAPI.playlist(playlist.id) + .catchError((e) => setState(() => _error = true)) + .then((Playlist p) { + if (p == null) return; + setState(() { + playlist = p; + }); + //Load tracks + _load(); + }); + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ListView( + controller: _scrollController, + children: [ + Container(height: 4.0,), + Card( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + CachedImage( + url: playlist.image.full, + height: 180.0, + ), + Container( + width: 180, + height: 200, //Card padding + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + playlist.title, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 2, + style: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold + ), + ), + Text( + playlist.user.name, + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).primaryColor, + fontSize: 18.0 + ), + ), + Container( + height: 8.0, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.audiotrack, + size: 32.0, + ), + Container(width: 8.0,), + Text(playlist.trackCount.toString(), style: TextStyle(fontSize: 16),) + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timelapse, + size: 32.0, + ), + Container(width: 8.0,), + Text(playlist.durationString, style: TextStyle(fontSize: 16),) + ], + ), + ], + ), + ) + ], + ), + ), + Container(height: 4.0,), + Card( + child: Padding( + padding: EdgeInsets.all(4.0), + child: Text( + playlist.description ?? '', + maxLines: 4, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16.0 + ), + ), + ) + ), + Card( + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + FlatButton( + child: Row( + children: [ + Icon(Icons.favorite, size: 32), + Container(width: 4,), + Text('Library') + ], + ), + onPressed: () async { + await deezerAPI.addFavoriteAlbum(playlist.id); + Fluttertoast.showToast( + msg: 'Added to library', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM + ); + }, + ), + MakePlaylistOffline(playlist), + FlatButton( + child: Row( + children: [ + Icon(Icons.file_download, size: 32.0,), + Container(width: 4,), + Text('Download') + ], + ), + onPressed: () { + downloadManager.addOfflinePlaylist(playlist, private: false); + }, + ) + ], + ), + ), + ...List.generate(playlist.tracks.length, (i) { + Track t = playlist.tracks[i]; + return TrackTile( + t, + onTap: () { + playerHelper.playFromPlaylist(playlist, t.id); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t, options: [ + (playlist.user.id == deezerAPI.userId) ? m.removeFromPlaylist(t, playlist) : Container(width: 0, height: 0,) + ]); + } + ); + }), + if (_loading) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator() + ], + ), + if (_error) + ErrorScreen() + ], + ) + ); + } +} + +class MakePlaylistOffline extends StatefulWidget { + Playlist playlist; + MakePlaylistOffline(this.playlist, {Key key}): super(key: key); + + @override + _MakePlaylistOfflineState createState() => _MakePlaylistOfflineState(); +} + +class _MakePlaylistOfflineState extends State { + bool _offline = false; + + @override + void 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 != null && widget.playlist.user.id != deezerAPI.userId) + await deezerAPI.addPlaylist(widget.playlist.id); + downloadManager.addOfflinePlaylist(widget.playlist, private: true); + setState(() { + _offline = true; + }); + return; + } + downloadManager.removeOfflinePlaylist(widget.playlist.id); + setState(() { + _offline = false; + }); + }, + ), + Container(width: 4.0,), + Text( + 'Offline', + style: TextStyle(fontSize: 16), + ) + ], + ); + } +} diff --git a/lib/ui/downloads_screen.dart b/lib/ui/downloads_screen.dart new file mode 100644 index 0000000..371585f --- /dev/null +++ b/lib/ui/downloads_screen.dart @@ -0,0 +1,113 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; + +import 'cached_image.dart'; +import '../api/download.dart'; + + +class DownloadTile extends StatelessWidget { + + final Download download; + DownloadTile(this.download); + + String get subtitle { + switch (download.state) { + case DownloadState.NONE: return ''; + case DownloadState.DOWNLOADING: + return '${filesize(download.received)} / ${filesize(download.total)}'; + case DownloadState.POST: + return 'Post processing...'; + case DownloadState.DONE: + return 'Done'; //Shouldn't be visible + } + return ''; + } + + Widget get progressBar { + switch (download.state) { + case DownloadState.DOWNLOADING: + return LinearProgressIndicator(value: download.received / download.total); + case DownloadState.POST: + return LinearProgressIndicator(); + default: + return Container(height: 0, width: 0,); + } + } + + Widget get trailing { + if (download.private) { + return Icon(Icons.offline_pin); + } + return Icon(Icons.sd_card); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(download.track.title), + subtitle: Text(subtitle), + leading: CachedImage( + url: download.track.albumArt.thumb, + ), + trailing: trailing, + ), + progressBar + ], + ); + } +} + +class DownloadsScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Downloads'), + ), + body: ListView( + children: [ + StreamBuilder( + stream: Stream.periodic(Duration(milliseconds: 500)), //Periodic to get current download progress + builder: (BuildContext context, AsyncSnapshot snapshot) { + + if (downloadManager.queue.length == 0) + return Container(width: 0, height: 0,); + + return Column( + children: List.generate(downloadManager.queue.length, (i) { + return DownloadTile(downloadManager.queue[i]); + }) + ); + }, + ), + FutureBuilder( + future: downloadManager.getFinishedDownloads(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data.length == 0) return Container(height: 0, width: 0,); + + return Column( + children: [ + Divider(), + Text( + 'History', + style: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold + ), + ), + ...List.generate(snapshot.data.length, (i) { + Download d = snapshot.data[i]; + return DownloadTile(d); + }) + ], + ); + }, + ) + ], + ) + ); + } +} \ No newline at end of file diff --git a/lib/ui/error.dart b/lib/ui/error.dart new file mode 100644 index 0000000..64eaf3b --- /dev/null +++ b/lib/ui/error.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class ErrorScreen extends StatelessWidget { + + final String message; + + ErrorScreen({this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error, + color: Colors.red, + size: 64.0, + ), + Container(height: 4.0,), + Text(message ?? 'Please check your connection and try again later...') + ], + ), + ); + } +} diff --git a/lib/ui/home_screen.dart b/lib/ui/home_screen.dart new file mode 100644 index 0000000..6ce4030 --- /dev/null +++ b/lib/ui/home_screen.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:freezer/api/player.dart'; +import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/menu.dart'; +import 'tiles.dart'; +import 'details_screens.dart'; +import '../settings.dart'; + +class HomeScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ListView( + children: [ + Container(height: 16.0,), + FreezerTitle(), + Container(height: 16.0,), + HomePageScreen() + ], + ); + } +} + +class FreezerTitle extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Text( + 'freezer', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Jost', + fontSize: 75, + fontStyle: FontStyle.italic, + letterSpacing: 7 + ), + ); + } +} + + + +class HomePageScreen extends StatefulWidget { + + final HomePage homePage; + final DeezerChannel channel; + HomePageScreen({this.homePage, this.channel, Key key}): super(key: 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 (_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) {} + //On background load from API + try { + if (settings.offlineMode) return; + HomePage _hp = await deezerAPI.homePage(); + if (_hp != null) { + if (_cancel) return; + if (_hp.sections.length == 0) return; + setState(() => _homePage = _hp); + //Save to cache + await _homePage.save(); + } + } catch (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.length == 0) { + _loadHomePage(); + return; + } + //Already have data + setState(() => _homePage = widget.homePage); + } + + @override + void initState() { + _load(); + super.initState(); + } + + @override + void dispose() { + _cancel = true; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_homePage == null) + return Center(child: CircularProgressIndicator(),); + if (_error) + return ErrorScreen(); + return SingleChildScrollView( + child: Column( + children: [ + ...List.generate(_homePage.sections.length, (i) { + HomePageSection section = _homePage.sections[i]; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + child: Text( + section.title, + textAlign: TextAlign.left, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 24.0), + ), + padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0) + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(section.items.length, (i) { + HomePageItem item = section.items[i]; + + switch (item.type) { + case HomePageItemType.SMARTTRACKLIST: + return SmartTrackListTile( + item.value, + onTap: () { + playerHelper.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(context); + m.defaultAlbumMenu(item.value); + }, + ); + case HomePageItemType.ARTIST: + return ArtistTile( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ArtistDetails(item.value) + )); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultArtistMenu(item.value); + }, + ); + case HomePageItemType.PLAYLIST: + return PlaylistCardTile( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => PlaylistDetails(item.value) + )); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultPlaylistMenu(item.value); + }, + ); + case HomePageItemType.CHANNEL: + return ChannelTile( + item.value, + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: Text(item.value.title.toString()),), + body: HomePageScreen(channel: item.value,), + ) + )); + }, + ); + } + return Container(height: 0, width: 0); + }), + ), + ), + Container(height: 16.0,) + ], + ); + }) + ], + ), + ); + } +} diff --git a/lib/ui/library.dart b/lib/ui/library.dart new file mode 100644 index 0000000..250e76f --- /dev/null +++ b/lib/ui/library.dart @@ -0,0 +1,610 @@ +import 'package:connectivity/connectivity.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:freezer/api/player.dart'; +import 'package:freezer/settings.dart'; +import 'package:freezer/ui/details_screens.dart'; +import 'package:freezer/ui/downloads_screen.dart'; +import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/tiles.dart'; + +import 'menu.dart'; +import 'settings_screen.dart'; +import 'player_bar.dart'; +import '../api/download.dart'; + +class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget { + + @override + Size get preferredSize => AppBar().preferredSize; + + @override + Widget build(BuildContext context) { + return AppBar( + title: Text('Library'), + actions: [ + IconButton( + icon: Icon(Icons.file_download), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => DownloadsScreen()) + ); + }, + ), + IconButton( + icon: Icon(Icons.settings), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => SettingsScreen()) + ); + }, + ), + ], + ); + } + +} + +class LibraryScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: LibraryAppBar(), + body: ListView( + children: [ + Container(height: 4.0,), + if (downloadManager.stopped) + ListTile( + title: Text('Downloads'), + leading: Icon(Icons.file_download), + subtitle: Text('Downloading is currently stopped, click here to resume.'), + onTap: () { + downloadManager.updateQueue(); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => DownloadsScreen() + )); + }, + ), + //Dirty if to not use columns + if (downloadManager.stopped) + Divider(), + + ListTile( + title: Text('Tracks'), + leading: Icon(Icons.audiotrack), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LibraryTracks()) + ); + }, + ), + ListTile( + title: Text('Albums'), + leading: Icon(Icons.album), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LibraryAlbums()) + ); + }, + ), + ListTile( + title: Text('Artists'), + leading: Icon(Icons.recent_actors), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LibraryArtists()) + ); + }, + ), + ListTile( + title: Text('Playlists'), + leading: Icon(Icons.playlist_play), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LibraryPlaylists()) + ); + }, + ), + ExpansionTile( + title: Text('Statistics'), + leading: Icon(Icons.insert_chart), + children: [ + FutureBuilder( + future: downloadManager.getStats(), + builder: (context, snapshot) { + if (snapshot.hasError) return ErrorScreen(); + if (!snapshot.hasData) return 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'), + leading: Icon(Icons.audiotrack), + trailing: Text(data[0]), + ), + ListTile( + title: Text('Offline albums'), + leading: Icon(Icons.album), + trailing: Text(data[1]), + ), + ListTile( + title: Text('Offline playlists'), + leading: Icon(Icons.playlist_add), + trailing: Text(data[2]), + ), + ListTile( + title: Text('Offline size'), + leading: Icon(Icons.sd_card), + trailing: Text(data[3]), + ), + ListTile( + title: Text('Free space'), + leading: Icon(Icons.disc_full), + trailing: Text(data[4]), + ), + ], + ); + }, + ) + ], + ) + ], + ), + ); + } +} + +class LibraryTracks extends StatefulWidget { + @override + _LibraryTracksState createState() => _LibraryTracksState(); +} + +class _LibraryTracksState extends State { + + bool _loading = false; + ScrollController _scrollController = ScrollController(); + List tracks = []; + List allTracks = []; + + Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId); + + Future _load() async { + ConnectivityResult connectivity = await Connectivity().checkConnectivity(); + if (connectivity != ConnectivityResult.none) { + setState(() => _loading = true); + int pos = tracks.length; + //Load another page of tracks from deezer + List _t; + try { + _t = await deezerAPI.playlistTracksPage(deezerAPI.favoritesPlaylistId, pos); + } catch (e) {} + //On error load offline + if (_t == null) { + await _loadOffline(); + return; + } + setState(() { + tracks.addAll(_t); + _loading = false; + }); + + } + } + + Future _loadOffline() async { + Playlist p = await downloadManager.getPlaylist(deezerAPI.favoritesPlaylistId); + if (p != null) setState(() { + tracks = p.tracks; + }); + } + + Future _loadAll() async { + List tracks = await downloadManager.allOfflineTracks(); + setState(() { + allTracks = tracks; + }); + } + + @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 tracks + _loadAll(); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Tracks'),), + body: ListView( + children: [ + Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(height: 8.0,), + Text( + 'Loved tracks', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24 + ), + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + MakePlaylistOffline(_playlist), + FlatButton( + child: Row( + children: [ + Icon(Icons.file_download, size: 32.0,), + Container(width: 4,), + Text('Download') + ], + ), + onPressed: () { + downloadManager.addOfflinePlaylist(_playlist, private: false); + }, + ) + ], + ) + ], + ), + ), + //Loved tracks + ...List.generate(tracks.length, (i) { + Track t = tracks[i]; + return TrackTile( + t, + onTap: () { + playerHelper.playFromTrackList(tracks, t.id, QueueSource( + id: deezerAPI.favoritesPlaylistId, + text: 'Favorites', + source: 'playlist' + )); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu( + t, + onRemove: () { + setState(() { + tracks.removeWhere((track) => t.id == track.id); + }); + } + ); + }, + ); + }), + if (_loading) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: CircularProgressIndicator(), + ) + ], + ), + Divider(), + Text( + 'All offline tracks', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold + ), + ), + Container(height: 8,), + ...List.generate(allTracks.length, (i) { + Track t = allTracks[i]; + return TrackTile( + t, + onTap: () { + playerHelper.playFromTrackList(allTracks, t.id, QueueSource( + id: 'allTracks', + text: 'All offline tracks', + source: 'offline' + )); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t); + }, + ); + }) + ], + ) + ); + } +} + + +class LibraryAlbums extends StatefulWidget { + @override + _LibraryAlbumsState createState() => _LibraryAlbumsState(); +} + +class _LibraryAlbumsState extends State { + + List _albums; + + Future _load() async { + if (settings.offlineMode) return; + try { + List albums = await deezerAPI.getAlbums(); + setState(() => _albums = albums); + } catch (e) {} + } + + @override + void initState() { + _load(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Albums'),), + body: ListView( + children: [ + Container(height: 8.0,), + if (!settings.offlineMode && _albums == null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator() + ], + ), + + if (_albums != null) + ...List.generate(_albums.length, (int i) { + Album a = _albums[i]; + return AlbumTile( + a, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AlbumDetails(a)) + ); + }, + onHold: () async { + MenuSheet m = MenuSheet(context); + m.defaultAlbumMenu(a, onRemove: () { + setState(() => _albums.remove(a)); + }); + }, + ); + }), + + FutureBuilder( + future: downloadManager.getOfflineAlbums(), + builder: (context, snapshot) { + if (snapshot.hasError || !snapshot.hasData || snapshot.data.length == 0) return Container(height: 0, width: 0,); + + List albums = snapshot.data; + return Column( + children: [ + Divider(), + Text( + 'Offline albums', + textAlign: TextAlign.center, + style: 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(context); + m.defaultAlbumMenu(a, onRemove: () { + setState(() { + albums.remove(a); + _albums.remove(a); + }); + }); + }, + ); + }) + ], + ); + }, + ) + ], + ), + ); + } +} + +class LibraryArtists extends StatefulWidget { + @override + _LibraryArtistsState createState() => _LibraryArtistsState(); +} + +class _LibraryArtistsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Artists'),), + body: FutureBuilder( + future: deezerAPI.getArtists(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + + if (snapshot.hasError) return ErrorScreen(); + if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),); + + return ListView( + children: [ + ...List.generate(snapshot.data.length, (i) { + Artist a = snapshot.data[i]; + return ArtistHorizontalTile( + a, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ArtistDetails(a)) + ); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultArtistMenu(a, onRemove: () { + setState(() => {}); + }); + }, + ); + }), + ], + ); + }, + ), + ); + } +} + + +class LibraryPlaylists extends StatefulWidget { + @override + _LibraryPlaylistsState createState() => _LibraryPlaylistsState(); +} + +class _LibraryPlaylistsState extends State { + + List _playlists; + + Future _load() async { + if (!settings.offlineMode) { + try { + List playlists = await deezerAPI.getPlaylists(); + setState(() => _playlists = playlists); + } catch (e) {} + } + } + + @override + void initState() { + _load(); + super.initState(); + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Playlists'),), + body: ListView( + children: [ + ListTile( + title: Text('Create new playlist'), + leading: Icon(Icons.playlist_add), + onTap: () { + if (settings.offlineMode) { + Fluttertoast.showToast( + msg: 'Cannot create playlists in offline mode', + gravity: ToastGravity.BOTTOM + ); + return; + } + MenuSheet m = MenuSheet(context); + m.createPlaylist(); + }, + ), + Divider(), + + if (!settings.offlineMode && _playlists == null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + ], + ), + + if (_playlists != null) + ...List.generate(_playlists.length, (int i) { + Playlist p = _playlists[i]; + return PlaylistTile( + p, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => PlaylistDetails(p) + )), + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultPlaylistMenu(p, onRemove: () { + setState(() => _playlists.remove(p)); + }); + }, + ); + }), + + FutureBuilder( + future: downloadManager.getOfflinePlaylists(), + builder: (context, snapshot) { + if (snapshot.hasError || !snapshot.hasData) return Container(height: 0, width: 0,); + if (snapshot.data.length == 0) return Container(height: 0, width: 0,); + + List playlists = snapshot.data; + return Column( + children: [ + Divider(), + Text( + 'Offline playlists', + textAlign: TextAlign.center, + style: 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(context); + m.defaultPlaylistMenu(p, onRemove: () { + setState(() { + playlists.remove(p); + _playlists.remove(p); + }); + }); + }, + ); + }) + ], + ); + }, + ) + + ], + ), + ); + } +} diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart new file mode 100644 index 0000000..6d61638 --- /dev/null +++ b/lib/ui/login_screen.dart @@ -0,0 +1,254 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/player.dart'; +import 'package:freezer/main.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +import '../settings.dart'; +import '../api/definitions.dart'; +import 'home_screen.dart'; + +class LoginWidget extends StatefulWidget { + + Function callback; + LoginWidget({this.callback, Key key}): super(key: key); + + @override + _LoginWidgetState createState() => _LoginWidgetState(); +} + +class _LoginWidgetState extends State { + + String _arl; + + //Initialize deezer etc + Future _init() async { + deezerAPI.arl = settings.arl; + await playerHelper.start(); + + //Pre-cache homepage + if (!await HomePage().exists()) { + await deezerAPI.authorize(); + settings.offlineMode = false; + HomePage hp = await deezerAPI.homePage(); + await hp.save(); + } + } + //Call _init() + void _start() async { + if (settings.arl != null) { + _init().then((_) { + if (widget.callback != null) widget.callback(); + }); + } + } + + @override + void didUpdateWidget(LoginWidget oldWidget) { + _start(); + super.didUpdateWidget(oldWidget); + } + + @override + void initState() { + _start(); + super.initState(); + } + + void errorDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Error'), + content: Text('Error logging in! Please check your token and internet connection and try again.'), + actions: [ + FlatButton( + child: Text('Dismiss'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + } + ); + } + + void _update() async { + setState(() => {}); + + //Try logging in + try { + deezerAPI.arl = settings.arl; + bool resp = await deezerAPI.authorize(); + if (resp == false) { //false, not null + setState(() => settings.arl = null); + errorDialog(); + } + //On error show dialog and reset to null + } catch (e) { + setState(() => settings.arl = null); + errorDialog(); + } + + await settings.save(); + _start(); + } + + @override + Widget build(BuildContext context) { + + //If arl non null, show loading + if (settings.arl != null) + return Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + + if (settings.arl == null) + return Scaffold( + body: Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: ListView( + children: [ + Container(height: 16.0,), + Text( + 'Welcome to', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16.0 + ), + ), + FreezerTitle(), + Container(height: 8.0,), + Text( + "Please login using your Deezer account.", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18.0 + ), + ), + Container(height: 16.0,), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32.0), + child: OutlineButton( + child: Text('Login using browser'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LoginBrowser(_update)) + ); + }, + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32.0), + child: OutlineButton( + child: Text('Login using token'), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Enter ARL'), + content: Container( + child: TextField( + onChanged: (String s) => _arl = s, + decoration: InputDecoration( + labelText: 'Token (ARL)' + ), + ), + ), + actions: [ + FlatButton( + child: Text('Save'), + onPressed: () { + settings.arl = _arl; + Navigator.of(context).pop(); + _update(); + }, + ) + ], + ); + } + ); + }, + ), + ), + Container(height: 16.0,), + Text( + "If you don't have account, you can register on deezer.com for free.", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18.0 + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32.0), + child: OutlineButton( + child: Text('Open in browser'), + onPressed: () { + InAppBrowser.openWithSystemBrowser(url: 'https://deezer.com/register'); + }, + ), + ), + Container(height: 8.0,), + Divider(), + Container(height: 8.0,), + Text( + "By using this app, you don't agree with the Deezer ToS", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18.0 + ), + ) + ], + ), + ), + ); + return null; + } +} + + +class LoginBrowser extends StatelessWidget { + + Function updateParent; + LoginBrowser(this.updateParent); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: Container( + + child: InAppWebView( + initialUrl: 'https://deezer.com/login', + onLoadStart: (InAppWebViewController controller, String url) async { + //Parse arl from url + if (url.startsWith('intent://deezer.page.link')) { + try { + //Parse url + Uri uri = Uri.parse(url); + //Actual url is in `link` query parameter + Uri linkUri = Uri.parse(uri.queryParameters['link']); + String arl = linkUri.queryParameters['arl']; + if (arl != null) { + settings.arl = arl; + Navigator.of(context).pop(); + updateParent(); + } + } catch (e) {} + } + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/menu.dart b/lib/ui/menu.dart new file mode 100644 index 0000000..dc8cd63 --- /dev/null +++ b/lib/ui/menu.dart @@ -0,0 +1,615 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/download.dart'; +import 'package:freezer/ui/details_screens.dart'; +import 'package:freezer/ui/error.dart'; + +import '../api/definitions.dart'; +import '../api/player.dart'; +import 'cached_image.dart'; + +class MenuSheet { + + BuildContext context; + + MenuSheet(this.context); + + //=================== + // DEFAULT + //=================== + + void show(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(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: [ + CachedImage( + url: track.albumArt.full, + height: 128, + width: 128, + ), + Container( + width: 240.0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + track.title, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 22.0, + fontWeight: FontWeight.bold + ), + ), + Text( + track.artistString, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: 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)?220:350, + ), + child: SingleChildScrollView( + child: Column( + children: options + ), + ), + ) + ], + ); + } + ); + } + + //Default track options + void defaultTrackMenu(Track track, {List options = const [], Function onRemove}) { + showWithTrack(track, [ + addToQueueNext(track), + addToQueue(track), + (track.favorite??false)?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track), + addToPlaylist(track), + downloadTrack(track), + showAlbum(track.album), + ...List.generate(track.artists.length, (i) => showArtist(track.artists[i])), + ...options + ]); + } + + //=================== + // TRACK OPTIONS + //=================== + + Widget addToQueueNext(Track t) => ListTile( + title: Text('Play next'), + leading: Icon(Icons.playlist_play), + onTap: () async { + if (playerHelper.queueIndex == -1) { + //First track + await AudioService.addQueueItem(t.toMediaItem()); + await AudioService.play(); + } else { + //Normal + await AudioService.addQueueItemAt( + t.toMediaItem(), playerHelper.queueIndex + 1); + } + _close(); + }); + + Widget addToQueue(Track t) => ListTile( + title: Text('Add to queue'), + leading: Icon(Icons.playlist_add), + onTap: () async { + await AudioService.addQueueItem(t.toMediaItem()); + _close(); + } + ); + + Widget addTrackFavorite(Track t) => ListTile( + title: Text('Add track to favorites'), + leading: 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!', + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT + ); + _close(); + } + ); + + Widget downloadTrack(Track t) => ListTile( + title: Text('Download'), + leading: Icon(Icons.file_download), + onTap: () async { + await downloadManager.addOfflineTrack(t, private: false); + _close(); + }, + ); + + Widget addToPlaylist(Track t) => ListTile( + title: Text('Add to playlist'), + leading: Icon(Icons.playlist_add), + onTap: () async { + + Playlist p; + + //Show dialog to pick playlist + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Select playlist'), + content: FutureBuilder( + future: deezerAPI.getPlaylists(), + builder: (context, snapshot) { + + if (snapshot.hasError) SizedBox( + height: 100, + child: ErrorScreen(), + ); + if (snapshot.connectionState != ConnectionState.done) return SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator(),), + ); + + List playlists = snapshot.data; + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...List.generate(playlists.length, (i) => ListTile( + title: Text(playlists[i].title), + leading: CachedImage( + url: playlists[i].image.thumb, + ), + onTap: () { + p = playlists[i]; + Navigator.of(context).pop(); + }, + )), + ListTile( + title: Text('Create new playlist'), + leading: Icon(Icons.add), + onTap: () { + Navigator.of(context).pop(); + showDialog( + context: context, + builder: (context) => CreatePlaylistDialog(tracks: [t],) + ); + }, + ) + ] + ), + ); + }, + ), + ); + } + ); + //Add to playlist, show toast + if (p != null) { + await deezerAPI.addToPlaylist(t.id, p.id); + //Update the playlist if offline + if (await downloadManager.checkOffline(playlist: p)) { + downloadManager.addOfflinePlaylist(p); + } + Fluttertoast.showToast( + msg: "Track added to ${p.title}", + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + ); + } + + _close(); + }, + ); + + Widget removeFromPlaylist(Track t, Playlist p) => ListTile( + title: Text('Remove from playlist'), + leading: Icon(Icons.delete), + onTap: () async { + await deezerAPI.removeFromPlaylist(t.id, p.id); + Fluttertoast.showToast( + msg: 'Track removed from ${p.title}', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + ); + _close(); + }, + ); + + Widget removeFavoriteTrack(Track t, {onUpdate}) => ListTile( + title: Text('Remove favorite'), + leading: 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); + } + Fluttertoast.showToast( + msg: 'Track removed from library', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM + ); + onUpdate(); + _close(); + }, + ); + + //Redirect to artist page (ie from track) + Widget showArtist(Artist a) => ListTile( + title: Text( + 'Go to ${a.name}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: Icon(Icons.recent_actors), + onTap: () { + _close(); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ArtistDetails(a)) + ); + }, + ); + + Widget showAlbum(Album a) => ListTile( + title: Text( + 'Go to ${a.title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: Icon(Icons.album), + onTap: () { + _close(); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AlbumDetails(a)) + ); + }, + ); + + + //=================== + // ALBUM + //=================== + + //Default album options + void defaultAlbumMenu(Album album, {List options = const [], Function onRemove}) { + show([ + album.library?removeAlbum(album, onRemove: onRemove):libraryAlbum(album), + downloadAlbum(album), + offlineAlbum(album), + ...options + ]); + } + + //=================== + // ALBUM OPTIONS + //=================== + + Widget downloadAlbum(Album a) => ListTile( + title: Text('Download'), + leading: Icon(Icons.file_download), + onTap: () async { + await downloadManager.addOfflineAlbum(a, private: false); + _close(); + } + ); + + Widget offlineAlbum(Album a) => ListTile( + title: Text('Make offline'), + leading: Icon(Icons.offline_pin), + onTap: () async { + await deezerAPI.addFavoriteAlbum(a.id); + await downloadManager.addOfflineAlbum(a, private: true); + _close(); + }, + ); + + Widget libraryAlbum(Album a) => ListTile( + title: Text('Add to library'), + leading: Icon(Icons.library_music), + onTap: () async { + await deezerAPI.addFavoriteAlbum(a.id); + Fluttertoast.showToast( + msg: 'Added to library', + gravity: ToastGravity.BOTTOM + ); + _close(); + }, + ); + + //Remove album from favorites + Widget removeAlbum(Album a, {Function onRemove}) => ListTile( + title: Text('Remove album'), + leading: Icon(Icons.delete), + onTap: () async { + await deezerAPI.removeAlbum(a.id); + await downloadManager.removeOfflineAlbum(a.id); + Fluttertoast.showToast( + msg: 'Album removed', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + ); + if (onRemove != null) onRemove(); + _close(); + }, + ); + + //=================== + // ARTIST + //=================== + + void defaultArtistMenu(Artist artist, {List options = const [], Function onRemove}) { + show([ + artist.library?removeArtist(artist, onRemove: onRemove):favoriteArtist(artist), + ...options + ]); + } + + //=================== + // ARTIST OPTIONS + //=================== + + Widget removeArtist(Artist a, {Function onRemove}) => ListTile( + title: Text('Remove from favorites'), + leading: Icon(Icons.delete), + onTap: () async { + await deezerAPI.removeArtist(a.id); + Fluttertoast.showToast( + msg: 'Artist removed from library', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM + ); + if (onRemove != null) onRemove(); + _close(); + }, + ); + + Widget favoriteArtist(Artist a) => ListTile( + title: Text('Add to favorites'), + leading: Icon(Icons.favorite), + onTap: () async { + await deezerAPI.addFavoriteArtist(a.id); + Fluttertoast.showToast( + msg: 'Added to library', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM + ); + _close(); + }, + ); + + //=================== + // PLAYLIST + //=================== + + void defaultPlaylistMenu(Playlist playlist, {List options = const [], Function onRemove}) { + show([ + playlist.library?removePlaylistLibrary(playlist, onRemove: onRemove):addPlaylistLibrary(playlist), + addPlaylistOffline(playlist), + downloadPlaylist(playlist), + ...options + ]); + } + + //=================== + // PLAYLIST OPTIONS + //=================== + + Widget removePlaylistLibrary(Playlist p, {Function onRemove}) => ListTile( + title: Text('Remove from library'), + leading: 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(); + _close(); + }, + ); + + Widget addPlaylistLibrary(Playlist p) => ListTile( + title: Text('Add playlist to library'), + leading: Icon(Icons.favorite), + onTap: () async { + await deezerAPI.addPlaylist(p.id); + Fluttertoast.showToast( + msg: 'Added playlist to library', + gravity: ToastGravity.BOTTOM + ); + _close(); + }, + ); + + Widget addPlaylistOffline(Playlist p) => ListTile( + title: Text('Make playlist offline'), + leading: Icon(Icons.offline_pin), + onTap: () async { + //Add to library + await deezerAPI.addPlaylist(p.id); + downloadManager.addOfflinePlaylist(p, private: true); + _close(); + }, + ); + + Widget downloadPlaylist(Playlist p) => ListTile( + title: Text('Download playlist'), + leading: Icon(Icons.file_download), + onTap: () async { + downloadManager.addOfflinePlaylist(p, private: false); + _close(); + }, + ); + + + //=================== + // OTHER + //=================== + + //Create playlist + void createPlaylist() { + showDialog( + context: context, + builder: (BuildContext context) { + return CreatePlaylistDialog(); + } + ); + } + + + void _close() => Navigator.of(context).pop(); +} + + +class CreatePlaylistDialog extends StatefulWidget { + + final List tracks; + CreatePlaylistDialog({this.tracks, Key key}): super(key: key); + + @override + _CreatePlaylistDialogState createState() => _CreatePlaylistDialogState(); +} + +class _CreatePlaylistDialogState extends State { + + int _playlistType = 1; + String _title = ''; + String _description = ''; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Create playlist'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: InputDecoration( + labelText: 'Title' + ), + onChanged: (String s) => _title = s, + ), + TextField( + onChanged: (String s) => _description = s, + decoration: InputDecoration( + labelText: 'Description' + ), + ), + Container(height: 4.0,), + DropdownButton( + value: _playlistType, + onChanged: (int v) { + setState(() => _playlistType = v); + }, + items: [ + DropdownMenuItem( + value: 1, + child: Text('Private'), + ), + DropdownMenuItem( + value: 2, + child: Text('Collaborative'), + ), + ], + ), + ], + ), + actions: [ + FlatButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + FlatButton( + child: Text('Create'), + onPressed: () async { + List tracks = []; + if (widget.tracks != null) { + tracks = widget.tracks.map((t) => t.id).toList(); + } + await deezerAPI.createPlaylist( + _title, + status: _playlistType, + description: _description, + trackIds: tracks + ); + Fluttertoast.showToast( + msg: 'Playlist created!', + gravity: ToastGravity.BOTTOM + ); + Navigator.of(context).pop(); + }, + ) + ], + ); + } +} diff --git a/lib/ui/player_bar.dart b/lib/ui/player_bar.dart new file mode 100644 index 0000000..6d422d3 --- /dev/null +++ b/lib/ui/player_bar.dart @@ -0,0 +1,160 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:freezer/settings.dart'; + +import '../api/player.dart'; +import 'cached_image.dart'; +import 'player_screen.dart'; + +class PlayerBar extends StatelessWidget { + double get progress { + if (AudioService.playbackState == null) return 0.0; + if (AudioService.currentMediaItem == null) return 0.0; + if (AudioService.currentMediaItem.duration.inSeconds == 0) return 0.0; //Division by 0 + return AudioService.playbackState.currentPosition.inSeconds / AudioService.currentMediaItem.duration.inSeconds; + } + + double iconSize = 32; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: Stream.periodic(Duration(milliseconds: 250)), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (AudioService.currentMediaItem == null) return Container(width: 0, height: 0,); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (BuildContext context) => PlayerScreen())), + leading: CachedImage( + width: 50, + height: 50, + url: AudioService.currentMediaItem.artUri, + ), + title: Text( + AudioService.currentMediaItem.displayTitle, + overflow: TextOverflow.clip, + maxLines: 1, + ), + subtitle: Text( + AudioService.currentMediaItem.displaySubtitle, + overflow: TextOverflow.clip, + maxLines: 1, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PrevNextButton(iconSize, prev: true, hidePrev: true,), + PlayPauseButton(iconSize), + PrevNextButton(iconSize) + ], + ) + ), + Container( + 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; + int i; + PrevNextButton(this.size, {this.prev = false, this.hidePrev = false}); + + @override + Widget build(BuildContext context) { + if (!prev) { + if (playerHelper.queueIndex == (AudioService.queue??[]).length - 1) { + return IconButton( + icon: Icon(Icons.skip_next), + iconSize: size, + onPressed: null, + ); + } + return IconButton( + icon: Icon(Icons.skip_next), + iconSize: size, + onPressed: () => AudioService.skipToNext(), + ); + } + if (prev) { + if (i == 0) { + if (hidePrev) { + return Container(height: 0, width: 0,); + } + return IconButton( + icon: Icon(Icons.skip_previous), + iconSize: size, + onPressed: null, + ); + } + return IconButton( + icon: Icon(Icons.skip_previous), + iconSize: size, + onPressed: () => AudioService.skipToPrevious(), + ); + } + return Container(); + } +} + + + +class PlayPauseButton extends StatelessWidget { + + final double size; + PlayPauseButton(this.size); + + @override + Widget build(BuildContext context) { + + //Playing + if (AudioService.playbackState?.playing??false) { + return IconButton( + iconSize: this.size, + icon: Icon(Icons.pause), + onPressed: () => AudioService.pause() + ); + } + + //Paused + if ((!AudioService.playbackState.playing && + AudioService.playbackState.processingState == AudioProcessingState.ready) || + //None state (stopped) + AudioService.playbackState.processingState == AudioProcessingState.none) { + return IconButton( + iconSize: this.size, + icon: Icon(Icons.play_arrow), + onPressed: () => AudioService.play() + ); + } + + switch (AudioService.playbackState.processingState) { + //Stopped/Error + case AudioProcessingState.error: + case AudioProcessingState.none: + case AudioProcessingState.stopped: + return Container(width: this.size, height: this.size); + //Loading, connecting, rewinding... + default: + return Container( + width: this.size, + height: this.size, + child: CircularProgressIndicator(), + ); + } + } +} diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart new file mode 100644 index 0000000..5e5ba21 --- /dev/null +++ b/lib/ui/player_screen.dart @@ -0,0 +1,581 @@ +import 'dart:ui'; +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/player.dart'; +import 'package:freezer/ui/menu.dart'; +import 'package:freezer/ui/tiles.dart'; + +import 'cached_image.dart'; +import '../api/definitions.dart'; +import 'player_bar.dart'; + + + +class PlayerScreen extends StatefulWidget { + @override + _PlayerScreenState createState() => _PlayerScreenState(); +} + +class _PlayerScreenState extends State { + + double iconSize = 48; + bool _lyrics = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: StreamBuilder( + stream: AudioService.playbackStateStream, + builder: (BuildContext context, AsyncSnapshot snapshot) { + + //Disable lyrics when skipping songs, loading + PlaybackState s = snapshot.data; + if (s != null && s.processingState != AudioProcessingState.ready && s.processingState != AudioProcessingState.buffering) _lyrics = false; + + return OrientationBuilder( + builder: (context, orientation) { + //Landscape + if (orientation == Orientation.landscape) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(16, 32, 16, 8), + child: Container( + width: 320, + child: Stack( + children: [ + CachedImage( + url: AudioService.currentMediaItem.artUri, + ), + if (_lyrics) LyricsWidget( + artUri: AudioService.currentMediaItem.artUri, + trackId: AudioService.currentMediaItem.id, + lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics, + height: 320.0, + ), + ], + ), + ) + ), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 32, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(8, 42, 8, 0), + child: Container( + width: 300, + child: PlayerScreenTopRow(), + ) + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AudioService.currentMediaItem.displayTitle, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold + ), + ), + Container(height: 4,), + Text( + AudioService.currentMediaItem.displaySubtitle, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: 18.0, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + Container( + width: 320, + child: SeekBar(), + ), + Container( + width: 320, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + PrevNextButton(iconSize, prev: true,), + PlayPauseButton(iconSize), + PrevNextButton(iconSize) + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(8, 0, 8, 16), + child: Container( + width: 300, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon(Icons.subtitles), + onPressed: () { + setState(() => _lyrics = !_lyrics); + }, + ), + Text( + AudioService.currentMediaItem.extras['qualityString'] + ), + IconButton( + icon: Icon(Icons.more_vert), + onPressed: () { + Track t = Track.fromMediaItem(AudioService.currentMediaItem); + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t); + }, + ) + ], + ), + ) + ) + ], + ), + ) + ], + ); + } + + //Portrait + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(28, 28, 28, 0), + child: PlayerScreenTopRow() + ), + Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Container( + height: 360, + child: Stack( + children: [ + CachedImage( + url: AudioService.currentMediaItem.artUri, + ), + if (_lyrics) LyricsWidget( + artUri: AudioService.currentMediaItem.artUri, + trackId: AudioService.currentMediaItem.id, + lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics, + height: 360.0, + ), + ], + ), + ) + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AudioService.currentMediaItem.displayTitle, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold + ), + ), + Container(height: 4,), + Text( + AudioService.currentMediaItem.displaySubtitle, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: 18.0, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + SeekBar(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + PrevNextButton(iconSize, prev: true,), + PlayPauseButton(iconSize), + PrevNextButton(iconSize) + ], + ), + //Container(height: 8.0,), + Padding( + padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon(Icons.subtitles), + onPressed: () { + setState(() => _lyrics = !_lyrics); + }, + ), + Text( + AudioService.currentMediaItem.extras['qualityString'] + ), + IconButton( + icon: Icon(Icons.more_vert), + onPressed: () { + Track t = Track.fromMediaItem(AudioService.currentMediaItem); + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t); + }, + ) + ], + ), + ) + ], + ); + + }, + ); + }, + ) + ); + } +} + +class LyricsWidget extends StatefulWidget { + + final Lyrics lyrics; + final String trackId; + final String artUri; + final double height; + LyricsWidget({this.artUri, this.lyrics, this.trackId, this.height, Key key}): super(key: key); + + @override + _LyricsWidgetState createState() => _LyricsWidgetState(); +} + +class _LyricsWidgetState extends State { + + bool _loading = true; + Lyrics _l; + Color _textColor = Colors.black; + ScrollController _scrollController = ScrollController(); + Timer _timer; + int _currentIndex; + double _boxHeight; + + Future _load() async { + //Get text color by album art (black or white) + if (widget.artUri != null) { + bool bw = await imagesDatabase.isDark(widget.artUri); + if (bw != null) setState(() => _textColor = bw?Colors.white:Colors.black); + } + + if (widget.lyrics.lyrics == null || widget.lyrics.lyrics.length == 0) { + //Load from api + try { + _l = await deezerAPI.lyrics(widget.trackId); + setState(() => _loading = false); + } catch (e) { + //Error Lyrics + setState(() => _l = Lyrics().error); + } + } else { + //Use provided lyrics + _l = widget.lyrics; + setState(() => _loading = false); + } + } + + @override + void initState() { + this._boxHeight = widget.height??400.0; + _load(); + Timer.periodic(Duration(milliseconds: 500), (timer) { + _timer = timer; + if (_loading) return; + //Update index of current lyric + setState(() { + _currentIndex = _l.lyrics.lastIndexWhere((l) => l.offset <= AudioService.playbackState.currentPosition); + }); + //Scroll to current lyric + if (_currentIndex <= 0) return; + _scrollController.animateTo( + (_boxHeight * _currentIndex), + duration: Duration(milliseconds: 250), + curve: Curves.ease + ); + + }); + super.initState(); + } + + @override + void dispose() { + if (_timer != null) _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: _boxHeight, + width: _boxHeight, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 7.0, + sigmaY: 7.0 + ), + child: Container( + child: _loading? + Center(child: CircularProgressIndicator(),) : + SingleChildScrollView( + controller: _scrollController, + child: Column( + children: List.generate(_l.lyrics.length, (i) { + return Container( + height: _boxHeight, + child: Center( + child: Text( + _l.lyrics[i].text, + textAlign: TextAlign.center, + style: TextStyle( + color: _textColor, + fontSize: 40.0, + fontWeight: (_currentIndex == i)?FontWeight.bold:FontWeight.normal + ), + ), + ) + ); + }), + ), + ) + ), + ), + ); + } +} + +//Top row containing QueueSource, queue... +class PlayerScreenTopRow extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Playing from: ' + playerHelper.queueSource.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle(fontSize: 16.0), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + RepeatButton(), + Container(width: 16.0,), + InkWell( + child: Icon(Icons.menu), + onTap: (){ + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => QueueScreen() + )); + }, + ), + ], + ) + ], + ); + } +} + + + +class RepeatButton extends StatefulWidget { + @override + _RepeatButtonState createState() => _RepeatButtonState(); +} + +class _RepeatButtonState extends State { + + Icon get icon { + switch (playerHelper.repeatType) { + case RepeatType.NONE: + return Icon(Icons.repeat); + case RepeatType.LIST: + return Icon( + Icons.repeat, + color: Theme.of(context).primaryColor, + ); + case RepeatType.TRACK: + return Icon( + Icons.repeat_one, + color: Theme.of(context).primaryColor, + ); + } + } + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + await playerHelper.changeRepeat(); + setState(() {}); + }, + child: icon, + ); + } +} + + + +class SeekBar extends StatefulWidget { + @override + _SeekBarState createState() => _SeekBarState(); +} + +class _SeekBarState extends State { + + bool _seeking = false; + double _pos; + + double get position { + if (_seeking) return _pos; + if (AudioService.playbackState == null) return 0.0; + double p = AudioService.playbackState.currentPosition.inMilliseconds.toDouble()??0.0; + 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 (AudioService.currentMediaItem == null) return 1.0; + return AudioService.currentMediaItem.duration.inMilliseconds.toDouble(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: Stream.periodic(Duration(milliseconds: 250)), + builder: (BuildContext context, AsyncSnapshot snapshot) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 0.0, horizontal: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _timeString(position), + style: TextStyle( + fontSize: 14.0 + ), + ), + Text( + _timeString(duration), + style: TextStyle( + fontSize: 14.0 + ), + ) + ], + ), + ), + Container( + height: 32.0, + child: Slider( + value: position, + max: duration, + onChangeStart: (double d) { + setState(() { + _seeking = true; + _pos = d; + }); + }, + onChanged: (double d) { + setState(() { + _pos = d; + }); + }, + onChangeEnd: (double d) async { + await AudioService.seekTo(Duration(milliseconds: d.round())); + setState(() { + _pos = d; + _seeking = false; + }); + }, + ), + ) + ], + ); + }, + ); + } +} + +class QueueScreen extends StatefulWidget { + @override + _QueueScreenState createState() => _QueueScreenState(); +} + +class _QueueScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Queue'), + actions: [ + IconButton( + icon: Icon(Icons.shuffle), + onPressed: () async { + await AudioService.customAction('shuffleQueue'); + setState(() => {}); + }, + ) + ], + ), + body: ListView.builder( + itemCount: AudioService.queue.length, + itemBuilder: (context, i) { + Track t = Track.fromMediaItem(AudioService.queue[i]); + return TrackTile( + t, + onTap: () async { + await AudioService.playFromMediaId(t.id); + Navigator.of(context).pop(); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t); + }, + ); + }, + ) + ); + } +} \ No newline at end of file diff --git a/lib/ui/search.dart b/lib/ui/search.dart new file mode 100644 index 0000000..02c3aea --- /dev/null +++ b/lib/ui/search.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:freezer/api/download.dart'; +import 'package:freezer/api/player.dart'; +import 'package:freezer/ui/details_screens.dart'; +import 'package:freezer/ui/menu.dart'; + +import 'tiles.dart'; +import '../api/deezer.dart'; +import '../api/definitions.dart'; +import '../settings.dart'; +import 'error.dart'; + +class SearchScreen extends StatefulWidget { + @override + _SearchScreenState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + + String _query; + bool _offline = settings.offlineMode; + + void _submit(BuildContext context, {String query}) { + if (query != null) _query = query; + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => SearchResultsScreen(_query, offline: _offline,)) + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Search'),), + body: ListView( + children: [ + Container(height: 16.0), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: TextField( + onChanged: (String s) => _query = s, + decoration: InputDecoration( + labelText: 'Search' + ), + onSubmitted: (String s) => _submit(context, query: s), + ), + ), + IconButton( + icon: Icon(Icons.search), + onPressed: () => _submit(context), + ) + ], + ), + ), + ListTile( + title: Text('Offline search'), + leading: Switch( + value: _offline, + onChanged: (v) { + if (settings.offlineMode) { + setState(() => _offline = true); + } else { + setState(() => _offline = v); + } + }, + ), + ) + ], + ), + ); + } +} + + + +class SearchResultsScreen extends StatelessWidget { + + final String query; + final bool offline; + + SearchResultsScreen(this.query, {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: AppBar( + title: Text('Search Results'), + ), + body: FutureBuilder( + future: _search(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + + if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),); + if (snapshot.hasError) return ErrorScreen(); + + SearchResults results = snapshot.data; + + if (results.empty) + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning, + size: 64, + ), + Text('No results!') + ], + ), + ); + + //Tracks + List tracks = []; + if (results.tracks != null && results.tracks.length != 0) { + tracks = [ + Text( + 'Tracks', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26.0 + ), + ), + ...List.generate(3, (i) { + if (results.tracks.length <= i) return Container(width: 0, height: 0,); + Track t = results.tracks[i]; + return TrackTile( + t, + onTap: () { + playerHelper.playFromTrackList(results.tracks, t.id, QueueSource( + text: 'Search', + id: query, + source: 'search' + )); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t); + }, + ); + }), + ListTile( + title: Text('Show all tracks'), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => TrackListScreen(results.tracks, QueueSource( + id: query, + source: 'search', + text: 'Search' + ))) + ); + }, + ) + ]; + } + + //Albums + List albums = []; + if (results.albums != null && results.albums.length != 0) { + albums = [ + Text( + 'Albums', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26.0 + ), + ), + ...List.generate(3, (i) { + if (results.albums.length <= i) return Container(height: 0, width: 0,); + Album a = results.albums[i]; + return AlbumTile( + a, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultAlbumMenu(a); + }, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AlbumDetails(a)) + ); + }, + ); + }), + ListTile( + title: Text('Show all albums'), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AlbumListScreen(results.albums)) + ); + }, + ) + ]; + } + + //Artists + List artists = []; + if (results.artists != null && results.artists.length != 0) { + artists = [ + Text( + 'Artists', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26.0 + ), + ), + 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: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ArtistDetails(a)) + ); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultArtistMenu(a); + }, + ); + }), + ) + ) + ]; + } + + //Playlists + List playlists = []; + if (results.playlists != null && results.playlists.length != 0) { + playlists = [ + Text( + 'Playlists', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26.0 + ), + ), + ...List.generate(3, (i) { + if (results.playlists.length <= i) return Container(height: 0, width: 0,); + Playlist p = results.playlists[i]; + return PlaylistTile( + p, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => PlaylistDetails(p)) + ); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultPlaylistMenu(p); + }, + ); + }), + ListTile( + title: Text('Show all playlists'), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => SearchResultPlaylists(results.playlists)) + ); + }, + ) + ]; + } + + return ListView( + children: [ + Container(height: 8.0,), + ...tracks, + Container(height: 8.0,), + ...albums, + Container(height: 8.0,), + ...artists, + Container(height: 8.0,), + ...playlists + ], + ); + }, + ) + ); + } +} + +//List all tracks +class TrackListScreen extends StatelessWidget { + + final QueueSource queueSource; + final List tracks; + + TrackListScreen(this.tracks, this.queueSource); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Tracks'),), + body: ListView.builder( + itemCount: tracks.length, + itemBuilder: (BuildContext context, int i) { + Track t = tracks[i]; + return TrackTile( + t, + onTap: () { + playerHelper.playFromTrackList(tracks, t.id, queueSource); + }, + onHold: () { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(t); + }, + ); + }, + ), + ); + } +} + +//List all albums +class AlbumListScreen extends StatelessWidget { + + final List albums; + AlbumListScreen(this.albums); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Albums'),), + 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(context); + m.defaultAlbumMenu(a); + }, + ); + }, + ), + ); + } +} + +class SearchResultPlaylists extends StatelessWidget { + + final List playlists; + SearchResultPlaylists(this.playlists); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Playlists'),), + 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(context); + m.defaultPlaylistMenu(p); + }, + ); + }, + ), + ); + } +} diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart new file mode 100644 index 0000000..21aac76 --- /dev/null +++ b/lib/ui/settings_screen.dart @@ -0,0 +1,655 @@ +import 'package:country_pickers/country.dart'; +import 'package:country_pickers/country_picker_dialog.dart'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_color_picker/flutter_material_color_picker.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/player_bar.dart'; +import 'package:language_pickers/language_pickers.dart'; +import 'package:language_pickers/languages.dart'; +import 'package:package_info/package_info.dart'; +import 'package:path_provider_ex/path_provider_ex.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../settings.dart'; +import '../main.dart'; + +import 'dart:io'; + +class SettingsScreen extends StatefulWidget { + @override + _SettingsScreenState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + + String _about = ''; + + @override + void initState() { + //Load about text + PackageInfo.fromPlatform().then((PackageInfo info) { + setState(() { + _about = '${info.appName} ${info.version}'; + }); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Settings'),), + body: ListView( + children: [ + ListTile( + title: Text('General'), + leading: Icon(Icons.settings), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => GeneralSettings() + )), + ), + ListTile( + title: Text('Appearance'), + leading: Icon(Icons.color_lens), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => AppearanceSettings()) + ), + ), + ListTile( + title: Text('Quality'), + leading: Icon(Icons.high_quality), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => QualitySettings()) + ), + ), + ListTile( + title: Text('Deezer'), + leading: Icon(Icons.equalizer), + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (context) => DeezerSettings() + )), + ), + Divider(), + Text( + _about, + textAlign: TextAlign.center, + ) + ], + ), + ); + } +} + +class AppearanceSettings extends StatefulWidget { + @override + _AppearanceSettingsState createState() => _AppearanceSettingsState(); +} + +class _AppearanceSettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Appearance'),), + body: ListView( + children: [ + ListTile( + title: Text('Theme'), + subtitle: Text('Currently: ${settings.theme.toString().split('.').last}'), + leading: Icon(Icons.color_lens), + onTap: () { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: Text('Select theme'), + children: [ + SimpleDialogOption( + child: Text('Light (default)'), + onPressed: () { + setState(() => settings.theme = Themes.Light); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ), + SimpleDialogOption( + child: Text('Dark'), + onPressed: () { + setState(() => settings.theme = Themes.Dark); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ), + SimpleDialogOption( + child: Text('Black (AMOLED)'), + onPressed: () { + setState(() => settings.theme = Themes.Black); + settings.save(); + updateTheme(); + Navigator.of(context).pop(); + }, + ) + ], + ); + } + ); + }, + ), + ListTile( + title: Text('Primary color'), + leading: Icon(Icons.format_paint), + subtitle: Text( + 'Selected color', + style: TextStyle( + color: settings.primaryColor + ), + ), + onTap: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Primary color'), + content: Container( + height: 200, + child: MaterialColorPicker( + 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'), + subtitle: Text('Warning: might be buggy'), + leading: Switch( + value: settings.useArtColor, + onChanged: (v) => setState(() => settings.updateUseArtColor(v)), + ), + ) + ], + ), + ); + } +} + + +class QualitySettings extends StatefulWidget { + @override + _QualitySettingsState createState() => _QualitySettingsState(); +} + +class _QualitySettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Quality'),), + body: ListView( + children: [ + ListTile( + title: Text('Mobile streaming'), + leading: Icon(Icons.network_cell), + ), + QualityPicker('mobile'), + Divider(), + ListTile( + title: Text('Wifi streaming'), + leading: Icon(Icons.network_wifi), + ), + QualityPicker('wifi'), + Divider(), + ListTile( + title: Text('Offline'), + leading: Icon(Icons.offline_pin), + ), + QualityPicker('offline'), + Divider(), + ListTile( + title: Text('External downloads'), + leading: Icon(Icons.file_download), + ), + QualityPicker('download'), + ], + ), + ); + } +} + +class QualityPicker extends StatefulWidget { + + final String field; + QualityPicker(this.field, {Key key}): super(key: key); + + @override + _QualityPickerState createState() => _QualityPickerState(); +} + +class _QualityPickerState extends State { + + 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) { + setState(() { + _quality = q; + }); + switch (widget.field) { + case 'mobile': + settings.mobileQuality = _quality; break; + case 'wifi': + settings.wifiQuality = _quality; break; + case 'download': + settings.downloadQuality = _quality; break; + case 'offline': + settings.offlineQuality = _quality; break; + } + settings.updateAudioServiceQuality(); + } + + @override + void dispose() { + //Save + settings.updateAudioServiceQuality(); + settings.save(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Text('MP3 128kbps'), + leading: Radio( + groupValue: _quality, + value: AudioQuality.MP3_128, + onChanged: (q) => _updateQuality(q), + ), + ), + ListTile( + title: Text('MP3 320kbps'), + leading: Radio( + groupValue: _quality, + value: AudioQuality.MP3_320, + onChanged: (q) => _updateQuality(q), + ), + ), + ListTile( + title: Text('FLAC'), + leading: Radio( + groupValue: _quality, + value: AudioQuality.FLAC, + onChanged: (q) => _updateQuality(q), + ), + ), + ], + ); + } +} + +class DeezerSettings extends StatefulWidget { + @override + _DeezerSettingsState createState() => _DeezerSettingsState(); +} + +class _DeezerSettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Deezer'),), + body: ListView( + children: [ + ListTile( + title: Text('Content language'), + subtitle: Text('Not app language, used in headers. Now: ${settings.deezerLanguage}'), + leading: Icon(Icons.language), + onTap: () { + showDialog( + context: context, + builder: (context) => LanguagePickerDialog( + titlePadding: EdgeInsets.all(8.0), + isSearchable: true, + title: Text('Select language'), + onValuePicked: (Language language) { + setState(() => settings.deezerLanguage = language.isoCode); + settings.save(); + }, + ) + ); + }, + ), + ListTile( + title: Text('Content country'), + subtitle: Text('Country used in headers. Now: ${settings.deezerCountry}'), + leading: Icon(Icons.vpn_lock), + onTap: () { + showDialog( + context: context, + builder: (context) => CountryPickerDialog( + titlePadding: EdgeInsets.all(8.0), + isSearchable: true, + onValuePicked: (Country country) { + setState(() => settings.deezerCountry = country.isoCode); + settings.save(); + }, + ) + ); + }, + ), + ListTile( + title: Text('Log tracks'), + subtitle: Text('Send track listen logs to Deezer, enable it for features like Flow to work properly'), + leading: Checkbox( + value: settings.logListen, + onChanged: (bool v) { + setState(() => settings.logListen = v); + settings.save(); + }, + ), + ) + ], + ), + ); + } +} + +class GeneralSettings extends StatefulWidget { + @override + _GeneralSettingsState createState() => _GeneralSettingsState(); +} + +class _GeneralSettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('General'),), + body: ListView( + children: [ + ListTile( + title: Text('Offline mode'), + subtitle: Text('Will be overwritten on start.'), + leading: Switch( + value: settings.offlineMode, + onChanged: (bool v) { + if (v) { + setState(() => settings.offlineMode = true); + return; + } + showDialog( + context: context, + builder: (context) { + deezerAPI.authorize().then((v) { + if (v) { + setState(() => settings.offlineMode = false); + } else { + Fluttertoast.showToast( + msg: 'Error logging in, check your internet connections.', + gravity: ToastGravity.BOTTOM, + toastLength: Toast.LENGTH_SHORT + ); + } + Navigator.of(context).pop(); + }); + return AlertDialog( + title: Text('Logging in...'), + content: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator() + ], + ) + ); + } + ); + }, + ), + ), + ListTile( + title: Text('Download path'), + leading: Icon(Icons.folder), + subtitle: Text(settings.downloadPath), + onTap: () async { + //Check permissions + if (!(await Permission.storage.request().isGranted)) return; + //Navigate + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => DirectoryPicker(settings.downloadPath, onSelect: (String p) { + setState(() => settings.downloadPath = p); + },) + )); + }, + ), + ListTile( + title: Text('Downloads naming'), + leading: Icon(Icons.text_format), + onTap: () { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + children: [ + ListTile( + title: Text('Default naming'), + subtitle: Text('01. Title'), + onTap: () { + settings.downloadNaming = DownloadNaming.DEFAULT; + Navigator.of(context).pop(); + settings.save(); + }, + ), + ListTile( + title: Text('Standalone naming'), + subtitle: Text('Artist - Title'), + onTap: () { + settings.downloadNaming = DownloadNaming.STANDALONE; + Navigator.of(context).pop(); + settings.save(); + }, + ), + ], + ); + } + ); + }, + ), + ListTile( + title: Text('Create download folder structure'), + subtitle: Text('Artist/Album/Track'), + leading: Switch( + value: settings.downloadFolderStructure, + onChanged: (v) { + setState(() => settings.downloadFolderStructure = v); + settings.save(); + }, + ), + ) + ], + ), + ); + } +} + +class DirectoryPicker extends StatefulWidget { + + final String initialPath; + final Function onSelect; + DirectoryPicker(this.initialPath, {this.onSelect, Key key}): super(key: key); + + @override + _DirectoryPickerState createState() => _DirectoryPickerState(); +} + +class _DirectoryPickerState extends State { + + String _path; + String _previous; + String _root; + + @override + void initState() { + _path = widget.initialPath; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Pick-a-Path'), + actions: [ + IconButton( + icon: Icon(Icons.sd_card), + onPressed: () { + String path = ''; + //Chose storage + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Select storage'), + content: FutureBuilder( + future: PathProviderEx.getStorageInfo(), + builder: (context, snapshot) { + if (snapshot.hasError) return ErrorScreen(); + if (!snapshot.hasData) return 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, (i) { + StorageInfo si = snapshot.data[i]; + return ListTile( + title: Text(si.rootDir), + leading: Icon(Icons.sd_card), + trailing: Text(filesize(si.availableBytes)), + onTap: () { + setState(() { + _path = si.appFilesDir; + //Android 5+ blocks sd card, so this prevents going outside + //app data dir, until permission request fix. + _root = si.rootDir; + if (i != 0) _root = si.appFilesDir; + }); + Navigator.of(context).pop(); + }, + ); + }) + ], + ); + }, + ), + ); + } + ); + } + ) + ], + ), + floatingActionButton: FloatingActionButton( + child: Icon(Icons.done), + onPressed: () { + //When folder confirmed + if (widget.onSelect != null) 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(Duration(milliseconds: 50), () => setState(() => _path = _previous)); + if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),); + + List data = snapshot.data; + return ListView( + children: [ + ListTile( + title: Text(_path), + ), + ListTile( + title: Text('Go up'), + leading: Icon(Icons.arrow_upward), + onTap: () { + setState(() { + if (_root == _path) { + Fluttertoast.showToast( + msg: 'Permission denied', + 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: Icon(Icons.folder), + onTap: () { + setState(() { + _previous = _path; + _path = f.path; + }); + }, + ); + } + return Container(height: 0, width: 0,); + }) + ], + ); + }, + ), + ); + } +} diff --git a/lib/ui/tiles.dart b/lib/ui/tiles.dart new file mode 100644 index 0000000..14e3378 --- /dev/null +++ b/lib/ui/tiles.dart @@ -0,0 +1,363 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; + +import '../api/definitions.dart'; +import 'cached_image.dart'; + +class TrackTile extends StatefulWidget { + + final Track track; + final Function onTap; + final Function onHold; + final Widget trailing; + + TrackTile(this.track, {this.onTap, this.onHold, this.trailing, Key key}): super(key: key); + + @override + _TrackTileState createState() => _TrackTileState(); +} + +class _TrackTileState extends State { + + StreamSubscription _subscription; + + bool get nowPlaying { + if (AudioService.currentMediaItem == null) return false; + return AudioService.currentMediaItem.id == widget.track.id; + } + + @override + void initState() { + //Listen to media item changes, update text color if currently playing + _subscription = AudioService.currentMediaItemStream.listen((event) { + setState(() {}); + }); + super.initState(); + } + + @override + void dispose() { + if (_subscription != null) _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + widget.track.title, + maxLines: 1, + style: TextStyle( + color: nowPlaying?Theme.of(context).primaryColor:null + ), + ), + subtitle: Text( + widget.track.artistString, + maxLines: 1, + ), + leading: CachedImage( + url: widget.track.albumArt.thumb, + ), + onTap: widget.onTap, + onLongPress: widget.onHold, + trailing: widget.trailing, + ); + } +} + +class AlbumTile extends StatelessWidget { + + final Album album; + final Function onTap; + final Function onHold; + final Widget trailing; + + AlbumTile(this.album, {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, + ), + onTap: onTap, + onLongPress: onHold, + trailing: trailing, + ); + } +} + +class ArtistTile extends StatelessWidget { + + final Artist artist; + final Function onTap; + final Function onHold; + + ArtistTile(this.artist, {this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 150, + child: Card( + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(height: 4,), + CachedImage( + url: artist.picture.thumb, + circular: true, + width: 64, + ), + Container(height: 4,), + Text( + artist.name, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.0 + ), + ), + Container(height: 4,), + ], + ), + ), + ) + ); + } +} + +class PlaylistTile extends StatelessWidget { + + final Playlist playlist; + final Function onTap; + final Function onHold; + final Widget trailing; + + PlaylistTile(this.playlist, {this.onHold, this.onTap, this.trailing}); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + playlist.title, + maxLines: 1, + ), + subtitle: Text( + playlist.user.name, + maxLines: 1, + ), + leading: CachedImage( + url: playlist.image.thumb, + ), + onTap: onTap, + onLongPress: onHold, + trailing: trailing, + ); + } +} + +class ArtistHorizontalTile extends StatelessWidget { + + final Artist artist; + final Function onTap; + final Function onHold; + final Widget trailing; + + ArtistHorizontalTile(this.artist, {this.onHold, this.onTap, this.trailing}); + + @override + Widget build(BuildContext context) { + return 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 Function onTap; + final Function onHold; + PlaylistCardTile(this.playlist, {this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(8), + child: CachedImage( + url: playlist.image.thumb, + width: 128, + height: 128, + ), + ), + Container( + width: 144, + child: Text( + playlist.title, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 16.0), + ), + ) + ], + ), + ) + ); + } +} + +class SmartTrackListTile extends StatelessWidget { + + final SmartTrackList smartTrackList; + final Function onTap; + final Function onHold; + SmartTrackListTile(this.smartTrackList, {this.onHold, this.onTap}); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(8.0), + child: CachedImage( + width: 128, + height: 128, + url: smartTrackList.cover.thumb, + ), + ), + Container( + width: 144.0, + child: Text( + smartTrackList.title, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.0 + ), + ), + ) + ], + ), + ), + ); + } +} + +class AlbumCard extends StatelessWidget { + + final Album album; + final Function onTap; + final Function onHold; + + AlbumCard(this.album, {this.onTap, this.onHold}); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + onTap: onTap, + onLongPress: onHold, + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(8.0), + child: CachedImage( + width: 128.0, + height: 128.0, + url: album.art.thumb, + ), + ), + Container( + width: 144.0, + child: Text( + album.title, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.0 + ), + ), + ) + ], + ), + ) + ); + } +} + +class ChannelTile extends StatelessWidget { + + final DeezerChannel channel; + final Function onTap; + ChannelTile(this.channel, {this.onTap}); + + Color _textColor() { + double luminance = channel.backgroundColor.computeLuminance(); + return (luminance>0.5)?Colors.black:Colors.white; + } + + @override + Widget build(BuildContext context) { + return Card( + color: channel.backgroundColor, + child: InkWell( + onTap: this.onTap, + child: Container( + width: 150, + height: 75, + child: Center( + child: Text( + channel.title, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.bold, + color: _textColor() + ), + ), + ), + ), + ) + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..ca3fd6a --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,805 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "0.39.10" + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + url: "https://pub.dartlang.org" + source: hosted + version: "0.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.9" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.0" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.2" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "7.1.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.3" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" + collection: + dependency: "direct main" + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.14.12" + connectivity: + dependency: "direct main" + description: + name: connectivity + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.8+6" + connectivity_macos: + dependency: transitive + description: + name: connectivity_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0+3" + connectivity_platform_interface: + dependency: transitive + description: + name: connectivity_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.6" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + country_pickers: + dependency: "direct main" + description: + name: country_pickers + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + crypto: + dependency: "direct main" + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + custom_navigator: + dependency: "direct main" + description: + name: custom_navigator + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.6" + dio: + dependency: "direct main" + description: + name: dio + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.9" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + disk_space: + dependency: "direct main" + description: + name: disk_space + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.3" + ext_storage: + dependency: "direct main" + description: + name: ext_storage + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + filesize: + dependency: "direct main" + description: + name: filesize + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.11" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0+3" + flutter_isolate: + dependency: transitive + description: + name: flutter_isolate + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0+14" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.4+1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + flutter_material_color_picker: + dependency: "direct main" + description: + name: flutter_material_color_picker + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + hex: + dependency: "direct main" + description: + name: hex + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0+3" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.4" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.12" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.4" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" + just_audio: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "70392a52590c95bd4b1ca35c7e92d30793c7c4d3" + url: "https://notabug.org/exttex/just_audio.git" + source: git + version: "0.1.10" + language_pickers: + dependency: "direct main" + description: + name: language_pickers + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0+1" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "0.11.4" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.6" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.8" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.6+3" + move_to_background: + dependency: "direct main" + description: + name: move_to_background + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + node_interop: + dependency: transitive + description: + name: node_interop + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + node_io: + dependency: transitive + description: + name: node_io + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + package_info: + dependency: "direct main" + description: + name: package_info + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.3" + path: + dependency: "direct main" + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.4" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.10" + path_provider_ex: + dependency: "direct main" + description: + name: path_provider_ex + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+1" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4+3" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.13" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + random_string: + dependency: "direct main" + description: + name: random_string + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + rxdart: + dependency: transitive + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.24.1" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.5" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.5" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.15" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+2" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7+15" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "3.6.1" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" +sdks: + dart: ">=2.7.0 <3.0.0" + flutter: ">=1.15.21 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9b84b6e --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,127 @@ +name: freezer +description: Freezer + +# 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.1.0 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + dio: ^3.0.9 + dio_cookie_manager: ^1.0.0 + cookie_jar: ^1.0.1 + json_annotation: ^3.0.1 + path_provider: ^1.6.9 + path: ^1.6.4 + sqflite: ^1.3.0+1 + crypto: ^2.1.4 + hex: ^0.1.2 + pointycastle: ^1.0.2 + ext_storage: ^1.0.3 + permission_handler: ^5.0.0+hotfix.6 + connectivity: ^0.4.8+6 + intl: ^0.16.1 + filesize: ^1.0.4 + fluttertoast: ^4.0.1 + palette_generator: ^0.2.3 + flutter_material_color_picker: ^1.0.5 + flutter_inappwebview: ^3.3.0+3 + custom_navigator: ^0.3.0 + language_pickers: ^0.2.0+1 + country_pickers: ^1.3.0 + package_info: ^0.4.1 + move_to_background: ^1.0.1 + flutter_local_notifications: ^1.4.4+1 + collection: ^1.14.12 + disk_space: ^0.0.3 + audio_service: ^0.11.0 + path_provider_ex: ^1.0.1 + random_string: ^2.0.1 + + just_audio: + git: https://notabug.org/exttex/just_audio.git + + # cupertino_icons: ^0.1.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + json_serializable: ^3.3.0 + build_runner: ^1.10.0 + +# 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: + - assets/cover.jpg + + 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: Jost + fonts: + - asset: assets/fonts/Jost-Italic.ttf + style: italic + + # 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 +