Merge branch 'master' into feature/atv

# Conflicts:
#	lib/ui/player_screen.dart
#	lib/ui/search.dart
This commit is contained in:
kilowatt 2020-11-30 20:21:21 +03:00
commit 1ea904ec8d
34 changed files with 1755 additions and 486 deletions

2
.gitignore vendored
View File

@ -2,6 +2,8 @@
freezerkey.jsk freezerkey.jsk
android/key.properties android/key.properties
just_audio/
# Miscellaneous # Miscellaneous
*.class *.class
*.log *.log

View File

@ -5,7 +5,7 @@ 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. If you want to report bug or request feature, please open an issue.
## Downloads: ## Downloads:
Downloads are currently distributed in Telegram channel: https://t.me/freezereleases Downloads are currently distributed in [Telegram channel](https://t.me/freezereleases) and the [Freezer website](https://www.freezer.life/)
**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. **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.
**App not installed** error - try different version (arm32/64) or uninstall old version. **App not installed** error - try different version (arm32/64) or uninstall old version.
@ -16,7 +16,7 @@ Install flutter SDK: https://flutter.dev/docs/get-started/install
Download source: Download source:
``` ```
git clone https://notabug.org/exttex/freezer git clone https://git.rip/freezer/freezer
git submodule init git submodule init
git submodule update git submodule update
``` ```
@ -28,39 +28,26 @@ flutter build apk
``` ```
NOTE: You have to use own keys, or build debug using `flutter build apk --debug` NOTE: You have to use own keys, or build debug using `flutter build apk --debug`
## Telegram group ## Links
https://t.me/freezerandroid Telegram group: https://t.me/freezerandroid
Discord server: https://discord.gg/7ap654Tp3z
## Credits ## Credits
Tobs: Beta tester **Tobs**: Beta tester
Bas Curtiz: Icon, Logo, Banner, Design suggestions **Xandar**: Community manager, helper, tester
Deemix: https://notabug.org/RemixDev/deemix **Bas Curtiz**: Icon, Logo, Banner, Design suggestions
Annexhack: Android Auto help and resources **Deemix**: https://git.rip/RemixDev/deemix/
**Annexhack**: Android Auto help and resources
### Translators: **Huge thanks to all the Crowdin translators and all the contributors to this project <3**
Xandar Null: Arabic
Markus: German
Andrea: Italian
Diego Hiro: Portuguese
Annexhack: Russian
Chino Pacia: Filipino
ArcherDelta & PetFix: Spanish
Shazzaam: Croatian
VIRGIN_KLM: Greek
koreezzz: Korean
Fwwwwwwwwwweze: French
kobyrevah: Hebrew
MicroMihai: Romanian
HoScHaKaL: Turkish
LenteraMalam: Indonesian
### just_audio, audio_service ### just_audio, audio_service
This app depends on modified just_audio and audio_service plugins with Deezer support. This app depends on modified just_audio and audio_service plugins with Deezer support.
Both plugins were originally written by ryanheise, all credits to him. Both plugins were originally written by ryanheise, all credits to him.
Forked versions for Freezer: Forked versions for Freezer:
https://notabug.org/exttex/just_audio/ https://git.rip/freezer/just_audio
https://notabug.org/exttex/audio_service/ https://git.rip/freezer/audio_service
## Support me ## Support me
BTC: `14hcr4PGbgqeXd3SoXY9QyJFNpyurgrL9y` BTC: `14hcr4PGbgqeXd3SoXY9QyJFNpyurgrL9y`

View File

@ -73,7 +73,9 @@ android {
} }
dependencies { dependencies {
implementation group: 'org', name: 'jaudiotagger', version: '2.0.3' //implementation group: 'org', name: 'jaudiotagger', version: '2.0.3'
implementation files('libs/jaudiotagger-2.2.3.jar')
implementation group: 'org.nanohttpd', name: 'nanohttpd', version: '2.3.1'
} }
flutter { flutter {

Binary file not shown.

View File

@ -27,7 +27,8 @@
android:name=".DownloadService" android:name=".DownloadService"
android:enabled="true" android:enabled="true"
android:exported="true" android:exported="true"
android:process="f.f.freezer.DownloadService" ></service> android:stopWithTask="false"
android:process=":FreezerDownloadService" ></service>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@ -1,6 +1,5 @@
package f.f.freezer; package f.f.freezer;
import android.content.Context;
import android.util.Log; import android.util.Log;
import org.jaudiotagger.audio.AudioFile; import org.jaudiotagger.audio.AudioFile;
@ -14,24 +13,17 @@ import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
import org.jaudiotagger.tag.images.Artwork; import org.jaudiotagger.tag.images.Artwork;
import org.jaudiotagger.tag.images.ArtworkFactory; import org.jaudiotagger.tag.images.ArtworkFactory;
import org.jaudiotagger.tag.reference.PictureTypes; import org.jaudiotagger.tag.reference.PictureTypes;
import org.jaudiotagger.tag.vorbiscomment.VorbisCommentFieldKey;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile; import java.io.RandomAccessFile;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.Arrays; import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Scanner; import java.util.Scanner;
import javax.crypto.Cipher; import javax.crypto.Cipher;
@ -74,21 +66,6 @@ public class Deezer {
public native void decryptFile(String trackId, String inputFilename, String outputFilename); public native void decryptFile(String trackId, String inputFilename, String outputFilename);
//Get guest SID cookie from deezer.com
public static String getSidCookie() throws Exception {
URL url = new URL("https://deezer.com/");
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setConnectTimeout(20000);
connection.setRequestMethod("HEAD");
String sid = "";
for (String cookie : connection.getHeaderFields().get("Set-Cookie")) {
if (cookie.startsWith("sid=")) {
sid = cookie.split(";")[0].split("=")[1];
}
}
return sid;
}
public JSONObject callGWAPI(String method, String params) throws Exception { public JSONObject callGWAPI(String method, String params) throws Exception {
//Get token //Get token
if (token == null) { if (token == null) {
@ -175,26 +152,6 @@ public class Deezer {
return out; return out;
} }
public int qualityFallback(String trackId, String md5origin, String mediaVersion, int originalQuality) throws Exception {
//Create HEAD requests to check if exists
URL url = new URL(getTrackUrl(trackId, md5origin, mediaVersion, originalQuality));
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
connection.setRequestProperty("Accept-Language", "*");
connection.setRequestProperty("Accept", "*/*");
int rc = connection.getResponseCode();
//Track not available
if (rc > 400) {
logger.warn("Quality fallback, response code: " + Integer.toString(rc) + ", current: " + Integer.toString(originalQuality));
//Returns -1 if no quality available
if (originalQuality == 1) return -1;
if (originalQuality == 3) return qualityFallback(trackId, md5origin, mediaVersion, 1);
if (originalQuality == 9) return qualityFallback(trackId, md5origin, mediaVersion, 3);
}
return originalQuality;
}
//Generate track download URL //Generate track download URL
public static String getTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) { public static String getTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) {
try { try {
@ -490,4 +447,138 @@ public class Deezer {
return output; return output;
} }
//Track decryption key
static byte[] getKey(String id) {
String secret = "g4el58wc0zvf9na1";
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(id.getBytes());
byte[] md5id = md5.digest();
String idmd5 = bytesToHex(md5id).toLowerCase();
String key = "";
for(int i=0; i<16; i++) {
int s0 = idmd5.charAt(i);
int s1 = idmd5.charAt(i+16);
int s2 = secret.charAt(i);
key += (char)(s0^s1^s2);
}
return key.getBytes();
} catch (Exception e) {
Log.e("E", e.toString());
return new byte[0];
}
}
//Decrypt 2048b of data
static byte[] decryptChunk(byte[] key, byte[] data) {
try {
byte[] IV = {00, 01, 02, 03, 04, 05, 06, 07};
SecretKeySpec Skey = new SecretKeySpec(key, "Blowfish");
Cipher cipher = Cipher.getInstance("Blowfish/CBC/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, Skey, new javax.crypto.spec.IvParameterSpec(IV));
return cipher.doFinal(data);
}catch (Exception e) {
Log.e("D", e.toString());
return new byte[0];
}
}
static class QualityInfo {
int quality;
String md5origin;
String mediaVersion;
String trackId;
int initialQuality;
DownloadLog logger;
QualityInfo(int quality, String trackId, String md5origin, String mediaVersion, DownloadLog logger) {
this.quality = quality;
this.initialQuality = quality;
this.trackId = trackId;
this.mediaVersion = mediaVersion;
this.md5origin = md5origin;
this.logger = logger;
}
boolean fallback(Deezer deezer) {
//Quality fallback
try {
qualityFallback();
//No quality
if (quality == -1)
throw new Exception("No quality to fallback to!");
//Success
return true;
} catch (Exception e) {
logger.warn("Quality fallback failed! ID: " + trackId + " " + e.toString());
quality = initialQuality;
}
//Track ID Fallback
JSONObject privateJson = null;
try {
//Fetch meta
JSONObject privateRaw = deezer.callGWAPI("deezer.pageTrack", "{\"sng_id\": \"" + trackId + "\"}");
privateJson = privateRaw.getJSONObject("results").getJSONObject("DATA");
if (privateJson.has("FALLBACK")) {
//Fetch new track
String fallbackId = privateJson.getJSONObject("FALLBACK").getString("SNG_ID");
if (!fallbackId.equals(trackId)) {
JSONObject newPrivate = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + fallbackId + "]}");
JSONObject trackData = newPrivate.getJSONObject("results").getJSONArray("data").getJSONObject(0);
trackId = trackData.getString("SNG_ID");
md5origin = trackData.getString("MD5_ORIGIN");
mediaVersion = trackData.getString("MEDIA_VERSION");
return fallback(deezer);
}
}
} catch (Exception e) {
logger.error("ID fallback failed! ID: " + trackId + " " + e.toString());
}
//ISRC Fallback
try {
JSONObject newTrackJson = Deezer.callPublicAPI("track", "isrc:" + privateJson.getString("ISRC"));
//Same track check
if (newTrackJson.getInt("id") == Integer.parseInt(trackId)) throw new Exception("No more to ISRC fallback!");
//Get private data
privateJson = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + newTrackJson.getInt("id") + "]}");
JSONObject trackData = privateJson.getJSONObject("results").getJSONArray("data").getJSONObject(0);
trackId = trackData.getString("SNG_ID");
md5origin = trackData.getString("MD5_ORIGIN");
mediaVersion = trackData.getString("MEDIA_VERSION");
return fallback(deezer);
} catch (Exception e) {
logger.error("ISRC Fallback failed, track unavailable! ID: " + trackId + " " + e.toString());
}
return false;
}
private void qualityFallback() throws Exception {
//Create HEAD requests to check if exists
URL url = new URL(getTrackUrl(trackId, md5origin, mediaVersion, quality));
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
connection.setRequestProperty("Accept-Language", "*");
connection.setRequestProperty("Accept", "*/*");
int rc = connection.getResponseCode();
//Track not available
if (rc > 400) {
logger.warn("Quality fallback, response code: " + Integer.toString(rc) + ", current: " + Integer.toString(quality));
//-1 if no quality available
if (quality == 1) {
quality = -1;
return;
}
if (quality == 3) quality = 1;
if (quality == 9) quality = 3;
qualityFallback();
}
}
}
} }

View File

@ -14,7 +14,7 @@ public class DownloadLog {
BufferedWriter writer; BufferedWriter writer;
//Open/Create file //Open/Create file
public void open(Context context) { void open(Context context) {
File file = new File(context.getExternalFilesDir(""), "download.log"); File file = new File(context.getExternalFilesDir(""), "download.log");
try { try {
if (!file.exists()) { if (!file.exists()) {
@ -27,7 +27,7 @@ public class DownloadLog {
} }
//Close log //Close log
public void close() { void close() {
try { try {
writer.close(); writer.close();
} catch (Exception ignored) { } catch (Exception ignored) {
@ -35,13 +35,13 @@ public class DownloadLog {
} }
} }
public String time() { String time() {
SimpleDateFormat format = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss"); SimpleDateFormat format = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss");
return format.format(Calendar.getInstance().getTime()); return format.format(Calendar.getInstance().getTime());
} }
//Write error to log //Write error to log
public void error(String info) { void error(String info) {
if (writer == null) return; if (writer == null) return;
String data = "E:" + time() + ": " + info; String data = "E:" + time() + ": " + info;
try { try {
@ -55,7 +55,7 @@ public class DownloadLog {
} }
//Write error to log with download info //Write error to log with download info
public void error(String info, Download download) { void error(String info, Download download) {
if (writer == null) return; if (writer == null) return;
String data = "E:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info; String data = "E:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info;
try { try {
@ -69,7 +69,7 @@ public class DownloadLog {
} }
//Write warning to log //Write warning to log
public void warn(String info) { void warn(String info) {
if (writer == null) return; if (writer == null) return;
String data = "W:" + time() + ": " + info; String data = "W:" + time() + ": " + info;
try { try {
@ -83,7 +83,7 @@ public class DownloadLog {
} }
//Write warning to log with download info //Write warning to log with download info
public void warn(String info, Download download) { void warn(String info, Download download) {
if (writer == null) return; if (writer == null) return;
String data = "W:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info; String data = "W:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info;
try { try {

View File

@ -119,7 +119,9 @@ public class DownloadService extends Service {
if (intent != null) if (intent != null)
activityMessenger = intent.getParcelableExtra("activityMessenger"); activityMessenger = intent.getParcelableExtra("activityMessenger");
return super.onStartCommand(intent, flags, startId); //return super.onStartCommand(intent, flags, startId);
//Prevent battery savers I guess
return START_STICKY;
} }
//Android O+ Notifications //Android O+ Notifications
@ -313,65 +315,25 @@ public class DownloadService extends Service {
return; return;
} }
//Quality fallback //Fallback
int newQuality; Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(this.download.quality, this.download.trackId, this.download.md5origin, this.download.mediaVersion, logger);
if (!download.isUserUploaded()) {
try { try {
newQuality = deezer.qualityFallback(download.trackId, download.md5origin, download.mediaVersion, download.quality); boolean res = qualityInfo.fallback(deezer);
} catch (Exception e) { if (!res)
logger.error("Quality fallback failed: " + e.toString(), download); throw new Exception("No more to fallback!");
download.state = Download.DownloadState.ERROR;
exit();
return;
}
//TrackID Fallback download.quality = qualityInfo.quality;
try {
if (newQuality == -1 && !download.isUserUploaded() && privateJson.has("FALLBACK")) {
logger.warn("TrackID Fallback!", download);
String fallbackId = privateJson.getJSONObject("FALLBACK").getString("SNG_ID");
JSONObject newPrivate = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + fallbackId + "]}");
JSONObject trackData = newPrivate.getJSONObject("results").getJSONArray("data").getJSONObject(0);
download.trackId = trackData.getString("SNG_ID");
download.md5origin = trackData.getString("MD5_ORIGIN");
download.mediaVersion = trackData.getString("MEDIA_VERSION");
run();
return;
}
} catch (Exception e) { } catch (Exception e) {
logger.error("ID fallback failed: " + e.toString(), download); logger.error("Fallback failed " + e.toString());
}
//ISRC Fallback
try {
if (newQuality == -1 && !download.isUserUploaded()) {
logger.warn("ISRC Fallback!", download);
JSONObject newTrackJson = Deezer.callPublicAPI("track", "isrc:" + trackJson.getString("isrc"));
//Same track check
if (newTrackJson.getInt("id") == trackJson.getInt("id")) throw new Exception("No more to fallback!");
//Get private data
JSONObject privateJson = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + newTrackJson.getInt("id") + "]}");
JSONObject trackData = privateJson.getJSONObject("results").getJSONArray("data").getJSONObject(0);
download.trackId = trackData.getString("SNG_ID");
download.md5origin = trackData.getString("MD5_ORIGIN");
download.mediaVersion = trackData.getString("MEDIA_VERSION");
run();
return;
}
} catch (Exception e) {
logger.error("ISRC Fallback failed, track unavailable! " + e.toString(), download);
download.state = Download.DownloadState.DEEZER_ERROR; download.state = Download.DownloadState.DEEZER_ERROR;
exit(); exit();
return; return;
} }
} else {
//No quality available //User uploaded MP3
if (newQuality == -1) { qualityInfo.quality = 3;
logger.error("No available quality!", download);
download.state = Download.DownloadState.DEEZER_ERROR;
exit();
return;
} }
download.quality = newQuality;
if (!download.priv) { if (!download.priv) {
//Check file //Check file
@ -379,7 +341,7 @@ public class DownloadService extends Service {
if (download.isUserUploaded()) { if (download.isUserUploaded()) {
outFile = new File(Deezer.generateUserUploadedMP3Filename(download.path, privateJson)); outFile = new File(Deezer.generateUserUploadedMP3Filename(download.path, privateJson));
} else { } else {
outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, newQuality)); outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, qualityInfo.quality));
} }
parentDir = new File(outFile.getParent()); parentDir = new File(outFile.getParent());
} catch (Exception e) { } catch (Exception e) {
@ -415,7 +377,7 @@ public class DownloadService extends Service {
} }
//Download //Download
String sURL = Deezer.getTrackUrl(download.trackId, download.md5origin, download.mediaVersion, newQuality); String sURL = Deezer.getTrackUrl(qualityInfo.trackId, qualityInfo.md5origin, qualityInfo.mediaVersion, qualityInfo.quality);
try { try {
URL url = new URL(sURL); URL url = new URL(sURL);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
@ -858,7 +820,23 @@ public class DownloadService extends Service {
//Parse settings from bundle sent from UI //Parse settings from bundle sent from UI
static DownloadSettings fromBundle(Bundle b) { static DownloadSettings fromBundle(Bundle b) {
return new DownloadSettings(b.getInt("downloadThreads"), b.getBoolean("overwriteDownload"), b.getBoolean("downloadLyrics"), b.getBoolean("trackCover"), b.getString("arl"), b.getBoolean("albumCover"), b.getBoolean("nomediaFiles")); JSONObject json;
try {
json = new JSONObject(b.getString("json"));
return new DownloadSettings(
json.getInt("downloadThreads"),
json.getBoolean("overwriteDownload"),
json.getBoolean("downloadLyrics"),
json.getBoolean("trackCover"),
json.getString("arl"),
json.getBoolean("albumCover"),
json.getBoolean("nomediaFiles")
);
} catch (Exception e) {
//Shouldn't happen
Log.e("ERR", "Error loading settings!");
return null;
}
} }
} }

View File

@ -23,13 +23,20 @@ import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.security.KeyManagementException;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import javax.crypto.Cipher; import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.FlutterEngine;
@ -48,6 +55,7 @@ public class MainActivity extends FlutterActivity {
Messenger serviceMessenger; Messenger serviceMessenger;
Messenger activityMessenger; Messenger activityMessenger;
SQLiteDatabase db; SQLiteDatabase db;
StreamServer streamServer;
//Data if started from intent //Data if started from intent
String intentPreload; String intentPreload;
@ -122,13 +130,7 @@ public class MainActivity extends FlutterActivity {
//Update settings from UI //Update settings from UI
if (call.method.equals("updateSettings")) { if (call.method.equals("updateSettings")) {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putInt("downloadThreads", (int)call.argument("downloadThreads")); bundle.putString("json", call.argument("json").toString());
bundle.putBoolean("overwriteDownload", (boolean)call.argument("overwriteDownload"));
bundle.putBoolean("downloadLyrics", (boolean)call.argument("downloadLyrics"));
bundle.putBoolean("trackCover", (boolean)call.argument("trackCover"));
bundle.putString("arl", (String)call.argument("arl"));
bundle.putBoolean("albumCover", (boolean)call.argument("albumCover"));
bundle.putBoolean("nomediaFiles", (boolean)call.argument("nomediaFiles"));
sendMessage(DownloadService.SERVICE_SETTINGS_UPDATE, bundle); sendMessage(DownloadService.SERVICE_SETTINGS_UPDATE, bundle);
result.success(null); result.success(null);
@ -185,6 +187,31 @@ public class MainActivity extends FlutterActivity {
result.success(System.getProperty("os.arch")); result.success(System.getProperty("os.arch"));
return; return;
} }
//Start streaming server
if (call.method.equals("startServer")) {
if (streamServer == null) {
//Get offline path
String offlinePath = getExternalFilesDir("offline").getAbsolutePath();
//Start server
streamServer = new StreamServer(call.argument("arl"), offlinePath);
streamServer.start();
}
result.success(null);
return;
}
//Get quality info from stream
if (call.method.equals("getStreamInfo")) {
if (streamServer == null) {
result.success(null);
return;
}
StreamServer.StreamInfo info = streamServer.streams.get(call.argument("id").toString());
if (info != null)
result.success(info.toJSON());
else
result.success(null);
return;
}
result.error("0", "Not implemented!", "Not implemented!"); result.error("0", "Not implemented!", "Not implemented!");
}))); })));
@ -208,14 +235,38 @@ public class MainActivity extends FlutterActivity {
@Override @Override
protected void onStart() { protected void onStart() {
super.onStart(); super.onStart();
//Bind downloader service //Bind downloader service
activityMessenger = new Messenger(new IncomingHandler(this)); activityMessenger = new Messenger(new IncomingHandler(this));
Intent intent = new Intent(this, DownloadService.class); Intent intent = new Intent(this, DownloadService.class);
intent.putExtra("activityMessenger", activityMessenger); intent.putExtra("activityMessenger", activityMessenger);
bindService(intent, connection, Context.BIND_AUTO_CREATE); startService(intent);
bindService(intent, connection, 0);
//Get DB //Get DB
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext()); DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
db = dbHelper.getWritableDatabase(); db = dbHelper.getWritableDatabase();
//Trust all SSL Certs - Credits to Kilowatt36
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return null;
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) {
}
}
};
SSLContext sc;
try {
sc = SSLContext.getInstance("SSL");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
} catch (NoSuchAlgorithmException | KeyManagementException e) {
Log.e(this.getLocalClassName(), e.getMessage());
}
} }
@Override @Override
@ -229,6 +280,14 @@ public class MainActivity extends FlutterActivity {
db.close(); db.close();
} }
@Override
protected void onDestroy() {
super.onDestroy();
//Stop server
if (streamServer != null)
streamServer.stop();
}
//Connection to download service //Connection to download service
private ServiceConnection connection = new ServiceConnection() { private ServiceConnection connection = new ServiceConnection() {
@Override @Override

View File

@ -0,0 +1,285 @@
package f.f.freezer;
import android.content.pm.PackageManager;
import android.util.Log;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.URL;
import java.util.HashMap;
import javax.net.ssl.HttpsURLConnection;
import fi.iki.elonen.NanoHTTPD;
public class StreamServer {
public HashMap<String, StreamInfo> streams = new HashMap<>();
private WebServer server;
private String host = "127.0.0.1";
private int port = 36958;
private String offlinePath;
//Shared log & API
private DownloadLog logger;
private Deezer deezer;
StreamServer(String arl, String offlinePath) {
//Initialize shared variables
logger = new DownloadLog();
deezer = new Deezer();
deezer.init(logger, arl);
this.offlinePath = offlinePath;
}
//Create server
void start() {
try {
server = new WebServer(host, port);
server.start();
} catch (Exception e) {
e.printStackTrace();
}
}
void stop() {
if (server != null)
server.stop();
}
//Information about streamed audio - for showing in UI
public class StreamInfo {
String format;
long size;
//"Stream" or "Offline"
String source;
StreamInfo(String format, long size, String source) {
this.format = format;
this.size = size;
this.source = source;
}
//For passing into UI
public HashMap<String, Object> toJSON() {
HashMap<String, Object> out = new HashMap<>();
out.put("format", format);
out.put("size", size);
out.put("source", source);
return out;
}
}
private class WebServer extends NanoHTTPD {
public WebServer(String hostname, int port) {
super(hostname, port);
}
@Override
public Response serve(IHTTPSession session) {
//Must be only GET
if (session.getMethod() != Method.GET)
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, MIME_PLAINTEXT, "Only GET request supported!");
//Parse range header
String rangeHeader = session.getHeaders().get("range");
int startBytes = 0;
boolean isRanged = false;
int end = -1;
if (rangeHeader != null && rangeHeader.startsWith("bytes")) {
isRanged = true;
String[] ranges = rangeHeader.split("=")[1].split("-");
startBytes = Integer.parseInt(ranges[0]);
if (ranges.length > 1 && !ranges[1].equals(" ")) {
end = Integer.parseInt(ranges[1]);
}
}
//Check query parameters
if (session.getParameters().keySet().size() < 4) {
//Play offline
if (session.getParameters().get("id") != null) {
return offlineStream(session, startBytes, end, isRanged);
}
//Missing QP
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid / Missing QP");
}
//Stream
return deezerStream(session, startBytes, end, isRanged);
}
private Response offlineStream(IHTTPSession session, int startBytes, int end, boolean isRanged) {
//Get path
String trackId = session.getParameters().get("id").get(0);
File file = new File(offlinePath, trackId);
long size = file.length();
//Read header
boolean isFlac = false;
try {
InputStream inputStream = new FileInputStream(file);
byte[] buffer = new byte[4];
inputStream.read(buffer, 0, 4);
inputStream.close();
if (new String(buffer).equals("fLaC"))
isFlac = true;
} catch (Exception e) {
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid file!");
}
//Open file
RandomAccessFile randomAccessFile;
try {
randomAccessFile = new RandomAccessFile(file, "r");
randomAccessFile.seek(startBytes);
} catch (Exception e) {
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!");
}
//Generate response
Response response = newFixedLengthResponse(
isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK,
isFlac ? "audio/flac" : "audio/mpeg",
new InputStream() {
@Override
public int read() throws IOException {
return 0;
}
//Pass thru
@Override
public int read(byte[] b, int off, int len) throws IOException {
return randomAccessFile.read(b, off, len);
}
},
((end == -1) ? size : end) - startBytes
);
//Ranged header
if (isRanged) {
String range = "bytes " + Integer.toString(startBytes) + "-" + Long.toString((end == -1) ? size - 1 : end);
range += "/" + Long.toString(size);
response.addHeader("Content-Range", range);
}
response.addHeader("Accept-Ranges", "bytes");
//Save stream info
streams.put(trackId, new StreamInfo((isFlac ? "FLAC" : "MP3"), size, "Offline"));
return response;
}
private Response deezerStream(IHTTPSession session, int startBytes, int end, boolean isRanged) {
//Get QP into Quality Info
Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(
Integer.parseInt(session.getParameters().get("q").get(0)),
session.getParameters().get("id").get(0),
session.getParameters().get("md5origin").get(0),
session.getParameters().get("mv").get(0),
logger
);
//Fallback
try {
boolean res = qualityInfo.fallback(deezer);
if (!res)
throw new Exception("No more to fallback!");
} catch (Exception e) {
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Fallback failed!");
}
//Calculate Deezer offsets
int deezerStart = startBytes - (startBytes % 2048);
int dropBytes = startBytes % 2048;
//Start download
String sURL = Deezer.getTrackUrl(qualityInfo.trackId, qualityInfo.md5origin, qualityInfo.mediaVersion, qualityInfo.quality);
try {
URL url = new URL(sURL);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
//Set headers
connection.setConnectTimeout(30000);
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
connection.setRequestProperty("Accept-Language", "*");
connection.setRequestProperty("Accept", "*/*");
connection.setRequestProperty("Range", "bytes=" + Integer.toString(deezerStart) + "-" + ((end == -1) ? "" : Integer.toString(end)));
connection.connect();
//Get decryption key
final byte[] key = Deezer.getKey(qualityInfo.trackId);
//Write response headers
Response outResponse = newFixedLengthResponse(
isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK,
(qualityInfo.quality == 9) ? "audio/flac" : "audio/mpeg",
new BufferedInputStream(new FilterInputStream(connection.getInputStream()) {
int counter = deezerStart / 2048;
int drop = dropBytes;
//Decryption stream
@Override
public int read(byte[] b, int off, int len) throws IOException {
//Read 2048b or EOF
byte[] buffer = new byte[2048];
int read = 0;
int totalRead = 0;
while (read != -1 && totalRead != 2048) {
read = in.read(buffer, totalRead, 2048 - totalRead);
if (read != -1)
totalRead += read;
}
if (totalRead == 0)
return -1;
//Not full chunk return unencrypted
if (totalRead != 2048) {
System.arraycopy(buffer, 0, b, off, totalRead);
return totalRead;
}
//Decrypt
if ((counter % 3) == 0) {
buffer = Deezer.decryptChunk(key, buffer);
}
//Drop bytes from rounding to 2048
if (drop > 0) {
int output = 2048 - drop;
System.arraycopy(buffer, drop, b, off, output);
drop = 0;
counter++;
return output;
}
//Copy
System.arraycopy(buffer, 0, b, off, 2048);
counter++;
return 2048;
}
}, 2048),
connection.getContentLength() - dropBytes
);
//Ranged header
if (isRanged) {
String range = "bytes " + Integer.toString(startBytes) + "-" + Integer.toString((end == -1) ? (connection.getContentLength() + deezerStart) - 1 : end);
range += "/" + Integer.toString(connection.getContentLength() + deezerStart);
outResponse.addHeader("Content-Range", range);
}
outResponse.addHeader("Accept-Ranges", "bytes");
//Save stream info, use original track id
streams.put(session.getParameters().get("id").get(0), new StreamInfo(
((qualityInfo.quality == 9) ? "FLAC" : "MP3"),
deezerStart + connection.getContentLength(),
"Stream"
));
return outResponse;
} catch (Exception e) {
e.printStackTrace();
}
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!");
}
}
}

BIN
assets/browse_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -1 +0,0 @@
Subproject commit 0ef1be20848b9553bc1191f5d119f768d6ce5ea5

View File

@ -29,19 +29,9 @@ class Cache {
@JsonKey(defaultValue: []) @JsonKey(defaultValue: [])
List<Track> history = []; List<Track> history = [];
//Cache playlist sort type {id: sort} //All sorting cached
@JsonKey(defaultValue: {}) @JsonKey(defaultValue: [])
Map<String, SortType> playlistSort; List<Sorting> sorts = [];
//Sort
@JsonKey(defaultValue: AlbumSortType.DEFAULT)
AlbumSortType albumSort;
@JsonKey(defaultValue: ArtistSortType.DEFAULT)
ArtistSortType artistSort;
@JsonKey(defaultValue: PlaylistSortType.DEFAULT)
PlaylistSortType libraryPlaylistSort;
@JsonKey(defaultValue: SortType.DEFAULT)
SortType trackSort;
//Sleep timer //Sleep timer
@JsonKey(ignore: true) @JsonKey(ignore: true)

View File

@ -16,21 +16,11 @@ Cache _$CacheFromJson(Map<String, dynamic> json) {
e == null ? null : Track.fromJson(e as Map<String, dynamic>)) e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList() ?? ?.toList() ??
[] []
..playlistSort = (json['playlistSort'] as Map<String, dynamic>)?.map( ..sorts = (json['sorts'] as List)
(k, e) => MapEntry(k, _$enumDecodeNullable(_$SortTypeEnumMap, e)), ?.map((e) =>
) ?? e == null ? null : Sorting.fromJson(e as Map<String, dynamic>))
{} ?.toList() ??
..albumSort = []
_$enumDecodeNullable(_$AlbumSortTypeEnumMap, json['albumSort']) ??
AlbumSortType.DEFAULT
..artistSort =
_$enumDecodeNullable(_$ArtistSortTypeEnumMap, json['artistSort']) ??
ArtistSortType.DEFAULT
..libraryPlaylistSort = _$enumDecodeNullable(
_$PlaylistSortTypeEnumMap, json['libraryPlaylistSort']) ??
PlaylistSortType.DEFAULT
..trackSort = _$enumDecodeNullable(_$SortTypeEnumMap, json['trackSort']) ??
SortType.DEFAULT
..searchHistory = ..searchHistory =
Cache._searchHistoryFromJson(json['searchHistory2'] as List) Cache._searchHistoryFromJson(json['searchHistory2'] as List)
..threadsWarning = json['threadsWarning'] as bool ?? false ..threadsWarning = json['threadsWarning'] as bool ?? false
@ -40,18 +30,25 @@ Cache _$CacheFromJson(Map<String, dynamic> json) {
Map<String, dynamic> _$CacheToJson(Cache instance) => <String, dynamic>{ Map<String, dynamic> _$CacheToJson(Cache instance) => <String, dynamic>{
'libraryTracks': instance.libraryTracks, 'libraryTracks': instance.libraryTracks,
'history': instance.history, 'history': instance.history,
'playlistSort': instance.playlistSort 'sorts': instance.sorts,
?.map((k, e) => MapEntry(k, _$SortTypeEnumMap[e])),
'albumSort': _$AlbumSortTypeEnumMap[instance.albumSort],
'artistSort': _$ArtistSortTypeEnumMap[instance.artistSort],
'libraryPlaylistSort':
_$PlaylistSortTypeEnumMap[instance.libraryPlaylistSort],
'trackSort': _$SortTypeEnumMap[instance.trackSort],
'searchHistory2': Cache._searchHistoryToJson(instance.searchHistory), 'searchHistory2': Cache._searchHistoryToJson(instance.searchHistory),
'threadsWarning': instance.threadsWarning, 'threadsWarning': instance.threadsWarning,
'lastUpdateCheck': instance.lastUpdateCheck, 'lastUpdateCheck': instance.lastUpdateCheck,
}; };
SearchHistoryItem _$SearchHistoryItemFromJson(Map<String, dynamic> json) {
return SearchHistoryItem(
json['data'],
_$enumDecodeNullable(_$SearchHistoryItemTypeEnumMap, json['type']),
);
}
Map<String, dynamic> _$SearchHistoryItemToJson(SearchHistoryItem instance) =>
<String, dynamic>{
'data': instance.data,
'type': _$SearchHistoryItemTypeEnumMap[instance.type],
};
T _$enumDecode<T>( T _$enumDecode<T>(
Map<T, dynamic> enumValues, Map<T, dynamic> enumValues,
dynamic source, { dynamic source, {
@ -84,49 +81,6 @@ T _$enumDecodeNullable<T>(
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue); return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
} }
const _$SortTypeEnumMap = {
SortType.DEFAULT: 'DEFAULT',
SortType.REVERSE: 'REVERSE',
SortType.ALPHABETIC: 'ALPHABETIC',
SortType.ARTIST: 'ARTIST',
};
const _$AlbumSortTypeEnumMap = {
AlbumSortType.DEFAULT: 'DEFAULT',
AlbumSortType.REVERSE: 'REVERSE',
AlbumSortType.ALPHABETIC: 'ALPHABETIC',
AlbumSortType.ARTIST: 'ARTIST',
AlbumSortType.DATE: 'DATE',
};
const _$ArtistSortTypeEnumMap = {
ArtistSortType.DEFAULT: 'DEFAULT',
ArtistSortType.REVERSE: 'REVERSE',
ArtistSortType.POPULARITY: 'POPULARITY',
ArtistSortType.ALPHABETIC: 'ALPHABETIC',
};
const _$PlaylistSortTypeEnumMap = {
PlaylistSortType.DEFAULT: 'DEFAULT',
PlaylistSortType.REVERSE: 'REVERSE',
PlaylistSortType.ALPHABETIC: 'ALPHABETIC',
PlaylistSortType.USER: 'USER',
PlaylistSortType.TRACK_COUNT: 'TRACK_COUNT',
};
SearchHistoryItem _$SearchHistoryItemFromJson(Map<String, dynamic> json) {
return SearchHistoryItem(
json['data'],
_$enumDecodeNullable(_$SearchHistoryItemTypeEnumMap, json['type']),
);
}
Map<String, dynamic> _$SearchHistoryItemToJson(SearchHistoryItem instance) =>
<String, dynamic>{
'data': instance.data,
'type': _$SearchHistoryItemTypeEnumMap[instance.type],
};
const _$SearchHistoryItemTypeEnumMap = { const _$SearchHistoryItemTypeEnumMap = {
SearchHistoryItemType.TRACK: 'TRACK', SearchHistoryItemType.TRACK: 'TRACK',
SearchHistoryItemType.ALBUM: 'ALBUM', SearchHistoryItemType.ALBUM: 'ALBUM',

View File

@ -466,5 +466,17 @@ class DeezerAPI {
}); });
return data['results']['data'].map<Track>((t) => Track.fromPrivateJson(t)).toList(); return data['results']['data'].map<Track>((t) => Track.fromPrivateJson(t)).toList();
} }
Future<List<ShowEpisode>> allShowEpisodes(String showId) async {
Map data = await callApi('deezer.pageShow', params: {
'country': settings.deezerCountry,
'lang': settings.deezerLanguage,
'nb': 1000,
'show_id': showId,
'start': 0,
'user_id': int.parse(deezerAPI.userId)
});
return data['results']['EPISODES']['data'].map<ShowEpisode>((e) => ShowEpisode.fromPrivateJson(e)).toList();
}
} }

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:freezer/api/cache.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -33,12 +34,13 @@ class Track {
bool favorite; bool favorite;
int diskNumber; int diskNumber;
bool explicit; bool explicit;
int favoriteDate; //Date added to playlist / favorites
int addedDate;
List<dynamic> playbackDetails; List<dynamic> playbackDetails;
Track({this.id, this.title, this.duration, this.album, this.playbackDetails, this.albumArt, Track({this.id, this.title, this.duration, this.album, this.playbackDetails, this.albumArt,
this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite, this.diskNumber, this.explicit, this.favoriteDate}); this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite, this.diskNumber, this.explicit, this.addedDate});
String get artistString => artists.map<String>((art) => art.name).join(', '); String get artistString => artists.map<String>((art) => art.name).join(', ');
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}"; String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
@ -141,7 +143,7 @@ class Track {
favorite: favorite, favorite: favorite,
diskNumber: int.parse(json['DISK_NUMBER']??'1'), diskNumber: int.parse(json['DISK_NUMBER']??'1'),
explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true:false, explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true:false,
favoriteDate: json['DATE_ADD'] addedDate: json['DATE_ADD']
); );
} }
Map<String, dynamic> toSQL({off = false}) => { Map<String, dynamic> toSQL({off = false}) => {
@ -702,6 +704,8 @@ class HomePageItem {
return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromPrivateJson(json)); return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromPrivateJson(json));
case 'album': case 'album':
return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromPrivateJson(json['data'])); return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromPrivateJson(json['data']));
case 'show':
return HomePageItem(type: HomePageItemType.SHOW, value: Show.fromPrivateJson(json['data']));
default: default:
return null; return null;
} }
@ -720,6 +724,8 @@ class HomePageItem {
return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromJson(json['value'])); return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromJson(json['value']));
case 'ALBUM': case 'ALBUM':
return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromJson(json['value'])); return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromJson(json['value']));
case 'SHOW':
return HomePageItem(type: HomePageItemType.SHOW, value: Show.fromPrivateJson(json['value']));
default: default:
return HomePageItem(); return HomePageItem();
} }
@ -762,7 +768,8 @@ enum HomePageItemType {
PLAYLIST, PLAYLIST,
ARTIST, ARTIST,
CHANNEL, CHANNEL,
ALBUM ALBUM,
SHOW
} }
enum HomePageSectionLayout { enum HomePageSectionLayout {
@ -798,3 +805,163 @@ class DeezerLinkResponse {
return null; return null;
} }
} }
//Sorting
enum SortType {
DEFAULT,
ALPHABETIC,
ARTIST,
ALBUM,
RELEASE_DATE,
POPULARITY,
USER,
TRACK_COUNT,
DATE_ADDED
}
enum SortSourceTypes {
//Library
TRACKS,
PLAYLISTS,
ALBUMS,
ARTISTS,
PLAYLIST
}
@JsonSerializable()
class Sorting {
SortType type;
bool reverse;
//For preserving sorting
String id;
SortSourceTypes sourceType;
Sorting({this.type = SortType.DEFAULT, this.reverse = false, this.id, this.sourceType});
//Find index of sorting from cache
static int index(SortSourceTypes type, {String id}) {
//Empty cache
if (cache.sorts == null) {
cache.sorts = [];
cache.save();
return null;
}
//Find index
int index;
if (id != null)
index = cache.sorts.indexWhere((s) => s.sourceType == type && s.id == id);
else
index = cache.sorts.indexWhere((s) => s.sourceType == type);
if (index == -1)
return null;
return index;
}
factory Sorting.fromJson(Map<String, dynamic> json) => _$SortingFromJson(json);
Map<String, dynamic> toJson() => _$SortingToJson(this);
}
@JsonSerializable()
class Show {
String name;
String description;
ImageDetails art;
String id;
Show({this.name, this.description, this.art, this.id});
//JSON
factory Show.fromPrivateJson(Map<dynamic, dynamic> json) => Show(
id: json['SHOW_ID'],
name: json['SHOW_NAME'],
art: ImageDetails.fromPrivateString(json['SHOW_ART_MD5'], type: 'talk'),
description: json['SHOW_DESCRIPTION']
);
factory Show.fromJson(Map<String, dynamic> json) => _$ShowFromJson(json);
Map<String, dynamic> toJson() => _$ShowToJson(this);
}
@JsonSerializable()
class ShowEpisode {
String id;
String title;
String description;
String url;
Duration duration;
String publishedDate;
ShowEpisode({this.id, this.title, this.description, this.url, this.duration, this.publishedDate});
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
//Generate MediaItem for playback
MediaItem toMediaItem(Show show) {
return MediaItem(
title: title,
displayTitle: title,
displaySubtitle: show.name,
album: show.name,
id: id,
extras: {
'showUrl': url,
'show': jsonEncode(show.toJson()),
'thumb': show.art.thumb
},
displayDescription: description,
duration: duration,
artUri: show.art.full
);
}
factory ShowEpisode.fromMediaItem(MediaItem mi) {
return ShowEpisode(
id: mi.id,
title: mi.title,
description: mi.displayDescription,
url: mi.extras['showUrl'],
duration: mi.duration,
);
}
//JSON
factory ShowEpisode.fromPrivateJson(Map<dynamic, dynamic> json) => ShowEpisode(
id: json['EPISODE_ID'],
title: json['EPISODE_TITLE'],
description: json['EPISODE_DESCRIPTION'],
url: json['EPISODE_DIRECT_STREAM_URL'],
duration: Duration(seconds: int.parse(json['DURATION'].toString())),
publishedDate: json['EPISODE_PUBLISHED_TIMESTAMP']
);
factory ShowEpisode.fromJson(Map<String, dynamic> json) => _$ShowEpisodeFromJson(json);
Map<String, dynamic> toJson() => _$ShowEpisodeToJson(this);
}
class StreamQualityInfo {
String format;
int size;
String source;
StreamQualityInfo({this.format, this.size, this.source});
factory StreamQualityInfo.fromJson(Map json) => StreamQualityInfo(
format: json['format'],
size: json['size'],
source: json['source']
);
int bitrate(Duration duration) {
if (size == null || size == 0) return 0;
int bitrate = (((size * 8) / 1000) / duration.inSeconds).round();
//Round to known values
if (bitrate > 122 && bitrate < 134)
return 128;
if (bitrate > 315 && bitrate < 325)
return 320;
return bitrate;
}
}

View File

@ -32,7 +32,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) {
favorite: json['favorite'] as bool, favorite: json['favorite'] as bool,
diskNumber: json['diskNumber'] as int, diskNumber: json['diskNumber'] as int,
explicit: json['explicit'] as bool, explicit: json['explicit'] as bool,
favoriteDate: json['favoriteDate'] as int, addedDate: json['addedDate'] as int,
); );
} }
@ -49,7 +49,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'favorite': instance.favorite, 'favorite': instance.favorite,
'diskNumber': instance.diskNumber, 'diskNumber': instance.diskNumber,
'explicit': instance.explicit, 'explicit': instance.explicit,
'favoriteDate': instance.favoriteDate, 'addedDate': instance.addedDate,
'playbackDetails': instance.playbackDetails, 'playbackDetails': instance.playbackDetails,
}; };
@ -387,3 +387,81 @@ Map<String, dynamic> _$DeezerChannelToJson(DeezerChannel instance) =>
'title': instance.title, 'title': instance.title,
'backgroundColor': DeezerChannel._colorToJson(instance.backgroundColor), 'backgroundColor': DeezerChannel._colorToJson(instance.backgroundColor),
}; };
Sorting _$SortingFromJson(Map<String, dynamic> json) {
return Sorting(
type: _$enumDecodeNullable(_$SortTypeEnumMap, json['type']),
reverse: json['reverse'] as bool,
id: json['id'] as String,
sourceType:
_$enumDecodeNullable(_$SortSourceTypesEnumMap, json['sourceType']),
);
}
Map<String, dynamic> _$SortingToJson(Sorting instance) => <String, dynamic>{
'type': _$SortTypeEnumMap[instance.type],
'reverse': instance.reverse,
'id': instance.id,
'sourceType': _$SortSourceTypesEnumMap[instance.sourceType],
};
const _$SortTypeEnumMap = {
SortType.DEFAULT: 'DEFAULT',
SortType.ALPHABETIC: 'ALPHABETIC',
SortType.ARTIST: 'ARTIST',
SortType.ALBUM: 'ALBUM',
SortType.RELEASE_DATE: 'RELEASE_DATE',
SortType.POPULARITY: 'POPULARITY',
SortType.USER: 'USER',
SortType.TRACK_COUNT: 'TRACK_COUNT',
SortType.DATE_ADDED: 'DATE_ADDED',
};
const _$SortSourceTypesEnumMap = {
SortSourceTypes.TRACKS: 'TRACKS',
SortSourceTypes.PLAYLISTS: 'PLAYLISTS',
SortSourceTypes.ALBUMS: 'ALBUMS',
SortSourceTypes.ARTISTS: 'ARTISTS',
SortSourceTypes.PLAYLIST: 'PLAYLIST',
};
Show _$ShowFromJson(Map<String, dynamic> json) {
return Show(
name: json['name'] as String,
description: json['description'] as String,
art: json['art'] == null
? null
: ImageDetails.fromJson(json['art'] as Map<String, dynamic>),
id: json['id'] as String,
);
}
Map<String, dynamic> _$ShowToJson(Show instance) => <String, dynamic>{
'name': instance.name,
'description': instance.description,
'art': instance.art,
'id': instance.id,
};
ShowEpisode _$ShowEpisodeFromJson(Map<String, dynamic> json) {
return ShowEpisode(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
url: json['url'] as String,
duration: json['duration'] == null
? null
: Duration(microseconds: json['duration'] as int),
publishedDate: json['publishedDate'] as String,
);
}
Map<String, dynamic> _$ShowEpisodeToJson(ShowEpisode instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'description': instance.description,
'url': instance.url,
'duration': instance.duration?.inMicroseconds,
'publishedDate': instance.publishedDate,
};

View File

@ -165,9 +165,10 @@ class PlayerHelper {
Future _loadQueuePlay(List<MediaItem> queue, String trackId) async { Future _loadQueuePlay(List<MediaItem> queue, String trackId) async {
await startService(); await startService();
await settings.updateAudioServiceQuality(); await settings.updateAudioServiceQuality();
await AudioService.customAction('setIndex', queue.indexWhere((m) => m.id == trackId));
await AudioService.updateQueue(queue); await AudioService.updateQueue(queue);
if (queue[0].id != trackId) // if (queue[0].id != trackId)
await AudioService.skipToQueueItem(trackId); // await AudioService.skipToQueueItem(trackId);
if (!AudioService.playbackState.playing) if (!AudioService.playbackState.playing)
AudioService.play(); AudioService.play();
} }
@ -236,6 +237,27 @@ class PlayerHelper {
source: 'playlist' source: 'playlist'
)); ));
} }
//Play episode from show, load whole show as queue
Future playShowEpisode(Show show, List<ShowEpisode> episodes, {int index = 0}) async {
QueueSource queueSource = QueueSource(
id: show.id,
text: show.name,
source: 'show'
);
//Generate media items
List<MediaItem> queue = episodes.map<MediaItem>((e) => e.toMediaItem(show)).toList();
//Load and play
await startService();
await settings.updateAudioServiceQuality();
await setQueueSource(queueSource);
await AudioService.customAction('setIndex', index);
await AudioService.updateQueue(queue);
if (!AudioService.playbackState.playing)
AudioService.play();
}
//Load tracks as queue, play track id, set queue source //Load tracks as queue, play track id, set queue source
Future playFromTrackList(List<Track> tracks, String trackId, QueueSource queueSource) async { Future playFromTrackList(List<Track> tracks, String trackId, QueueSource queueSource) async {
await startService(); await startService();
@ -340,7 +362,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
//Quality string //Quality string
if (_queueIndex != -1 && _queueIndex < _queue.length) { if (_queueIndex != -1 && _queueIndex < _queue.length) {
Map extras = mediaItem.extras; Map extras = mediaItem.extras;
extras['qualityString'] = event.qualityString??''; extras['qualityString'] = '';
_queue[_queueIndex] = mediaItem.copyWith(extras: extras); _queue[_queueIndex] = mediaItem.copyWith(extras: extras);
} }
//Update //Update
@ -530,7 +552,6 @@ class AudioPlayerTask extends BackgroundAudioTask {
this._queue = q; this._queue = q;
AudioServiceBackground.setQueue(_queue); AudioServiceBackground.setQueue(_queue);
//Load //Load
_queueIndex = 0;
await _loadQueue(); await _loadQueue();
//await _player.seek(Duration.zero, index: 0); //await _player.seek(Duration.zero, index: 0);
} }
@ -550,8 +571,8 @@ class AudioPlayerTask extends BackgroundAudioTask {
_audioSource = ConcatenatingAudioSource(children: sources); _audioSource = ConcatenatingAudioSource(children: sources);
//Load in just_audio //Load in just_audio
try { try {
await _player.load(_audioSource); await _player.load(_audioSource, initialPosition: Duration.zero, initialIndex: qi);
await _player.seek(Duration.zero, index: qi); // await _player.seek(Duration.zero, index: qi);
} catch (e) { } catch (e) {
//Error loading tracks //Error loading tracks
} }
@ -571,9 +592,15 @@ class AudioPlayerTask extends BackgroundAudioTask {
String _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/'); String _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
File f = File(p.join(_offlinePath, mediaItem.id)); File f = File(p.join(_offlinePath, mediaItem.id));
if (await f.exists()) { if (await f.exists()) {
return f.path; //return f.path;
//Stream server URL
return 'http://localhost:36958/?id=${mediaItem.id}';
} }
//Show episode direct link
if (mediaItem.extras['showUrl'] != null)
return mediaItem.extras['showUrl'];
//Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer //Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer
//This just returns fake url that contains metadata //This just returns fake url that contains metadata
List playbackDetails = jsonDecode(mediaItem.extras['playbackDetails']); List playbackDetails = jsonDecode(mediaItem.extras['playbackDetails']);
@ -583,7 +610,8 @@ class AudioPlayerTask extends BackgroundAudioTask {
if (conn == ConnectivityResult.wifi) quality = wifiQuality; if (conn == ConnectivityResult.wifi) quality = wifiQuality;
if ((playbackDetails??[]).length < 2) return null; if ((playbackDetails??[]).length < 2) return null;
String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}'; //String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
String url = 'http://localhost:36958/?q=$quality&mv=${playbackDetails[1]}&md5origin=${playbackDetails[0]}&id=${mediaItem.id}';
return url; return url;
} }
@ -632,6 +660,10 @@ class AudioPlayerTask extends BackgroundAudioTask {
AudioServiceBackground.setQueue(_queue); AudioServiceBackground.setQueue(_queue);
_broadcastState(); _broadcastState();
} }
//Set index without affecting playback for loading
if (name == 'setIndex') {
this._queueIndex = args;
}
return true; return true;
} }

File diff suppressed because one or more lines are too long

View File

@ -281,6 +281,17 @@ const language_en_us = {
"Unsupported platform!": "Unsupported platform!", "Unsupported platform!": "Unsupported platform!",
"Freezer Updates": "Freezer Updates", "Freezer Updates": "Freezer Updates",
"Update to latest version in the settings.": "Update to latest version in the settings.", "Update to latest version in the settings.": "Update to latest version in the settings.",
"Release date": "Release date" "Release date": "Release date",
//0.6.4 Strings:
"Shows": "Shows",
"Charts": "Charts",
"Browse": "Browse",
"Quick access": "Quick access",
"Play mix": "Play mix",
"Share show": "Share show",
"Date added": "Date added",
"Discord": "Discord",
"Official Discord server": "Official Discord server"
} }
}; };

View File

@ -71,7 +71,7 @@ class _FreezerAppState extends State<FreezerApp> {
}); });
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
systemNavigationBarColor: settings.themeData.bottomAppBarColor, systemNavigationBarColor: settings.themeData.bottomAppBarColor,
systemNavigationBarIconBrightness: settings.isDark? Brightness.light : Brightness.dark systemNavigationBarIconBrightness: settings.isDark ? Brightness.light : Brightness.dark,
)); ));
} }
@ -174,6 +174,8 @@ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateM
void initState() { void initState() {
navigatorKey = GlobalKey<NavigatorState>(); navigatorKey = GlobalKey<NavigatorState>();
_startStreamingServer();
//Start with parameters //Start with parameters
_setupUniLinks(); _setupUniLinks();
_loadPreloadInfo(); _loadPreloadInfo();
@ -188,6 +190,10 @@ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateM
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
} }
void _startStreamingServer() async {
await DownloadManager.platform.invokeMethod("startServer", {"arl": settings.arl});
}
void _prepareQuickActions() { void _prepareQuickActions() {
final QuickActions quickActions = QuickActions(); final QuickActions quickActions = QuickActions();
quickActions.initialize((type) { quickActions.initialize((type) {

View File

@ -122,15 +122,7 @@ class Settings {
//JSON to forward into download service //JSON to forward into download service
Map getServiceSettings() { Map getServiceSettings() {
return { return {"json": jsonEncode(this.toJson())};
"downloadThreads": downloadThreads,
"overwriteDownload": overwriteDownload,
"downloadLyrics": downloadLyrics,
"trackCover": trackCover,
"arl": arl,
"albumCover": albumCover,
"nomediaFiles": nomediaFiles
};
} }
void updateUseArtColor(bool v) { void updateUseArtColor(bool v) {

View File

@ -27,7 +27,8 @@ const supportedLocales = [
const Locale('hi', 'IN'), const Locale('hi', 'IN'),
const Locale('sk', 'SK'), const Locale('sk', 'SK'),
const Locale('cs', 'CZ'), const Locale('cs', 'CZ'),
const Locale('fil', 'PH') const Locale('fil', 'PH'),
const Locale('uwu', 'UWU')
]; ];
extension Localization on String { extension Localization on String {

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
@ -683,13 +684,6 @@ class _DiscographyScreenState extends State<DiscographyScreen> {
} }
} }
enum SortType {
DEFAULT,
REVERSE,
ALPHABETIC,
ARTIST
}
class PlaylistDetails extends StatefulWidget { class PlaylistDetails extends StatefulWidget {
Playlist playlist; Playlist playlist;
@ -704,25 +698,30 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
Playlist playlist; Playlist playlist;
bool _loading = false; bool _loading = false;
bool _error = false; bool _error = false;
SortType _sort = SortType.DEFAULT; Sorting _sort;
ScrollController _scrollController = ScrollController(); ScrollController _scrollController = ScrollController();
//Get sorted playlist //Get sorted playlist
List<Track> get sorted { List<Track> get sorted {
List<Track> tracks = new List.from(playlist.tracks??[]); List<Track> tracks = new List.from(playlist.tracks??[]);
switch (_sort) { switch (_sort.type) {
case SortType.ALPHABETIC: case SortType.ALPHABETIC:
tracks.sort((a, b) => a.title.compareTo(b.title)); tracks.sort((a, b) => a.title.compareTo(b.title));
return tracks; break;
case SortType.ARTIST: case SortType.ARTIST:
tracks.sort((a, b) => a.artists[0].name.toLowerCase().compareTo(b.artists[0].name.toLowerCase())); tracks.sort((a, b) => a.artists[0].name.toLowerCase().compareTo(b.artists[0].name.toLowerCase()));
return tracks; break;
case SortType.REVERSE: case SortType.DATE_ADDED:
return tracks.reversed.toList(); tracks.sort((a, b) => (a.addedDate??0) - (b.addedDate??0));
break;
case SortType.DEFAULT: case SortType.DEFAULT:
default: default:
return tracks; break;
} }
//Reverse
if (_sort.reverse)
return tracks.reversed.toList();
return tracks;
} }
//Load tracks from api //Load tracks from api
@ -748,23 +747,40 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
//Load cached playlist sorting //Load cached playlist sorting
void _restoreSort() async { void _restoreSort() async {
if (cache.playlistSort == null) { //Find index
cache.playlistSort = {}; int index = Sorting.index(SortSourceTypes.PLAYLIST, id: playlist.id);
await cache.save(); if (index == null)
return; return;
}
if (cache.playlistSort[playlist.id] != null) {
//Preload tracks //Preload tracks
if (playlist.tracks.length < playlist.trackCount) { if (playlist.tracks.length < playlist.trackCount) {
playlist = await deezerAPI.fullPlaylist(playlist.id); playlist = await deezerAPI.fullPlaylist(playlist.id);
} }
setState(() => _sort = cache.playlistSort[playlist.id]); setState(() => _sort = cache.sorts[index]);
}
Future _reverse() async {
setState(() => _sort.reverse = !_sort.reverse);
//Save sorting in cache
int index = Sorting.index(SortSourceTypes.TRACKS);
if (index != null) {
cache.sorts[index] = _sort;
} else {
cache.sorts.add(_sort);
}
await cache.save();
//Preload for sorting
if (playlist.tracks.length < playlist.trackCount) {
playlist = await deezerAPI.fullPlaylist(playlist.id);
} }
} }
@override @override
void initState() { void initState() {
playlist = widget.playlist; playlist = widget.playlist;
_sort = Sorting(sourceType: SortSourceTypes.PLAYLIST, id: playlist.id);
//If scrolled past 90% load next tracks //If scrolled past 90% load next tracks
_scrollController.addListener(() { _scrollController.addListener(() {
double off = _scrollController.position.maxScrollExtent * 0.90; double off = _scrollController.position.maxScrollExtent * 0.90;
@ -918,21 +934,22 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
//Preload whole playlist //Preload whole playlist
playlist = await deezerAPI.fullPlaylist(playlist.id); playlist = await deezerAPI.fullPlaylist(playlist.id);
} }
setState(() => _sort = s); setState(() => _sort.type = s);
//Save sort type to cache //Save sort type to cache
cache.playlistSort[playlist.id] = s; int index = Sorting.index(SortSourceTypes.PLAYLIST, id: playlist.id);
cache.save(); if (index == null) {
cache.sorts.add(_sort);
} else {
cache.sorts[index] = _sort;
}
await cache.save();
}, },
itemBuilder: (context) => <PopupMenuEntry<SortType>>[ itemBuilder: (context) => <PopupMenuEntry<SortType>>[
PopupMenuItem( PopupMenuItem(
value: SortType.DEFAULT, value: SortType.DEFAULT,
child: Text('Default'.i18n, style: popupMenuTextStyle()), child: Text('Default'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem(
value: SortType.REVERSE,
child: Text('Reverse'.i18n, style: popupMenuTextStyle()),
),
PopupMenuItem( PopupMenuItem(
value: SortType.ALPHABETIC, value: SortType.ALPHABETIC,
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
@ -941,8 +958,16 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
value: SortType.ARTIST, value: SortType.ARTIST,
child: Text('Artist'.i18n, style: popupMenuTextStyle()), child: Text('Artist'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem(
value: SortType.DATE_ADDED,
child: Text('Date added'.i18n, style: popupMenuTextStyle()),
),
], ],
), ),
IconButton(
icon: Icon(_sort.reverse ? FontAwesome5.sort_alpha_up : FontAwesome5.sort_alpha_down),
onPressed: () => _reverse(),
),
Container(width: 4.0) Container(width: 4.0)
], ],
), ),
@ -1039,3 +1064,136 @@ class _MakePlaylistOfflineState extends State<MakePlaylistOffline> {
); );
} }
} }
class ShowScreen extends StatefulWidget {
Show show;
ShowScreen(this.show, {Key key}): super(key: key);
@override
_ShowScreenState createState() => _ShowScreenState();
}
class _ShowScreenState extends State<ShowScreen> {
Show _show;
bool _loading = true;
bool _error = false;
List<ShowEpisode> _episodes;
Future _load() async {
//Fetch
List<ShowEpisode> e;
try {
e = await deezerAPI.allShowEpisodes(_show.id);
} catch (e) {
setState(() {
_loading = false;
_error = true;
});
return;
}
setState(() {
_episodes = e;
_loading = false;
});
}
@override
void initState() {
_show = widget.show;
_load();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FreezerAppBar(_show.name),
body: ListView(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
CachedImage(
url: _show.art.full,
rounded: true,
width: MediaQuery.of(context).size.width / 2 - 16,
),
Container(
width: MediaQuery.of(context).size.width / 2 - 16,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Text(
_show.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold
)
),
Container(height: 8.0),
Text(
_show.description,
maxLines: 6,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16.0
),
)
],
),
)
],
),
),
Container(height: 4.0),
FreezerDivider(),
//Error
if (_error)
ErrorScreen(),
//Loading
if (_loading)
Padding(
padding: EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator()
],
),
),
//Data
if (!_loading && !_error)
...List.generate(_episodes.length, (i) {
ShowEpisode e = _episodes[i];
return ShowEpisodeTile(
e,
trailing: IconButton(
icon: Icon(Icons.more_vert),
onPressed: () {
MenuSheet m = MenuSheet(context);
m.defaultShowEpisodeMenu(_show, e);
},
),
onTap: () async {
await playerHelper.playShowEpisode(_show, _episodes, index: i);
},
);
})
],
),
);
}
}

View File

@ -280,6 +280,15 @@ class HomePageItemWidget extends StatelessWidget {
)); ));
}, },
); );
case HomePageItemType.SHOW:
return ShowCard(
item.value,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ShowScreen(item.value)
));
},
);
} }
return Container(height: 0, width: 0); return Container(height: 0, width: 0);
} }

View File

@ -1,5 +1,6 @@
import 'package:connectivity/connectivity.dart'; import 'package:connectivity/connectivity.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
@ -220,26 +221,44 @@ class _LibraryTracksState extends State<LibraryTracks> {
List<Track> tracks = []; List<Track> tracks = [];
List<Track> allTracks = []; List<Track> allTracks = [];
int trackCount; int trackCount;
SortType _sort = SortType.DEFAULT; Sorting _sort = Sorting(sourceType: SortSourceTypes.TRACKS);
Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId); Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId);
List<Track> get _sorted { List<Track> get _sorted {
List<Track> tcopy = List.from(tracks); List<Track> tcopy = List.from(tracks);
tcopy.sort((a, b) => a.favoriteDate.compareTo(b.favoriteDate)); tcopy.sort((a, b) => a.addedDate.compareTo(b.addedDate));
switch (_sort) { switch (_sort.type) {
case SortType.ALPHABETIC: case SortType.ALPHABETIC:
tcopy.sort((a, b) => a.title.compareTo(b.title)); tcopy.sort((a, b) => a.title.compareTo(b.title));
return tcopy; break;
case SortType.ARTIST: case SortType.ARTIST:
tcopy.sort((a, b) => a.artists[0].name.toLowerCase().compareTo(b.artists[0].name.toLowerCase())); tcopy.sort((a, b) => a.artists[0].name.toLowerCase().compareTo(b.artists[0].name.toLowerCase()));
return tcopy; break;
case SortType.REVERSE:
return tcopy.reversed.toList();
case SortType.DEFAULT: case SortType.DEFAULT:
default: default:
break;
}
//Reverse
if (_sort.reverse)
return tcopy.reversed.toList();
return tcopy; return tcopy;
} }
Future _reverse() async {
setState(() => _sort.reverse = !_sort.reverse);
//Save sorting in cache
int index = Sorting.index(SortSourceTypes.TRACKS);
if (index != null) {
cache.sorts[index] = _sort;
} else {
cache.sorts.add(_sort);
}
await cache.save();
//Preload for sorting
if (tracks.length < (trackCount??0))
_loadFull();
} }
Future _load() async { Future _load() async {
@ -274,6 +293,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
//Update //Update
setState(() { setState(() {
trackCount = favPlaylist.trackCount; trackCount = favPlaylist.trackCount;
if (tracks.length == 0)
tracks = favPlaylist.tracks; tracks = favPlaylist.tracks;
_makeFavorite(); _makeFavorite();
_loading = false; _loading = false;
@ -306,7 +326,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
//Load all tracks //Load all tracks
Future _loadFull() async { Future _loadFull() async {
if (tracks.length < (trackCount??0)) { if (tracks.length == 0 || tracks.length < (trackCount??0)) {
Playlist p; Playlist p;
try { try {
p = await deezerAPI.fullPlaylist(deezerAPI.favoritesPlaylistId); p = await deezerAPI.fullPlaylist(deezerAPI.favoritesPlaylistId);
@ -315,6 +335,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
setState(() { setState(() {
tracks = p.tracks; tracks = p.tracks;
trackCount = p.trackCount; trackCount = p.trackCount;
_sort = _sort;
}); });
} }
} }
@ -348,13 +369,16 @@ class _LibraryTracksState extends State<LibraryTracks> {
if (_scrollController.position.pixels > off) _load(); if (_scrollController.position.pixels > off) _load();
}); });
_sort = cache.trackSort??SortType.DEFAULT;
_load(); _load();
//Load all offline tracks //Load all offline tracks
_loadAllOffline(); _loadAllOffline();
if (_sort != SortType.DEFAULT) //Load sorting
int index = Sorting.index(SortSourceTypes.TRACKS);
if (index != null)
setState(() => _sort = cache.sorts[index]);
if (_sort.type != SortType.DEFAULT || _sort.reverse)
_loadFull(); _loadFull();
super.initState(); super.initState();
@ -366,6 +390,12 @@ class _LibraryTracksState extends State<LibraryTracks> {
appBar: FreezerAppBar( appBar: FreezerAppBar(
'Tracks'.i18n, 'Tracks'.i18n,
actions: [ actions: [
IconButton(
icon: Icon(_sort.reverse ? FontAwesome5.sort_alpha_up : FontAwesome5.sort_alpha_down),
onPressed: () async {
await _reverse();
}
),
PopupMenuButton( PopupMenuButton(
child: Icon(Icons.sort, size: 32.0), child: Icon(Icons.sort, size: 32.0),
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
@ -374,8 +404,14 @@ class _LibraryTracksState extends State<LibraryTracks> {
if (tracks.length < (trackCount??0)) if (tracks.length < (trackCount??0))
await _loadFull(); await _loadFull();
setState(() => _sort = s); setState(() => _sort.type = s);
cache.trackSort = s; //Save sorting in cache
int index = Sorting.index(SortSourceTypes.TRACKS);
if (index != null) {
cache.sorts[index] = _sort;
} else {
cache.sorts.add(_sort);
}
await cache.save(); await cache.save();
}, },
itemBuilder: (context) => <PopupMenuEntry<SortType>>[ itemBuilder: (context) => <PopupMenuEntry<SortType>>[
@ -383,10 +419,6 @@ class _LibraryTracksState extends State<LibraryTracks> {
value: SortType.DEFAULT, value: SortType.DEFAULT,
child: Text('Default'.i18n, style: popupMenuTextStyle()), child: Text('Default'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem(
value: SortType.REVERSE,
child: Text('Reverse'.i18n, style: popupMenuTextStyle()),
),
PopupMenuItem( PopupMenuItem(
value: SortType.ALPHABETIC, value: SortType.ALPHABETIC,
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
@ -498,14 +530,6 @@ class _LibraryTracksState extends State<LibraryTracks> {
} }
enum AlbumSortType {
DEFAULT,
REVERSE,
ALPHABETIC,
ARTIST,
DATE
}
class LibraryAlbums extends StatefulWidget { class LibraryAlbums extends StatefulWidget {
@override @override
_LibraryAlbumsState createState() => _LibraryAlbumsState(); _LibraryAlbumsState createState() => _LibraryAlbumsState();
@ -514,27 +538,28 @@ class LibraryAlbums extends StatefulWidget {
class _LibraryAlbumsState extends State<LibraryAlbums> { class _LibraryAlbumsState extends State<LibraryAlbums> {
List<Album> _albums; List<Album> _albums;
AlbumSortType _sort = AlbumSortType.DEFAULT; Sorting _sort = Sorting(sourceType: SortSourceTypes.ALBUMS);
ScrollController _scrollController = ScrollController(); ScrollController _scrollController = ScrollController();
List<Album> get _sorted { List<Album> get _sorted {
List<Album> albums = List.from(_albums); List<Album> albums = List.from(_albums);
albums.sort((a, b) => a.favoriteDate.compareTo(b.favoriteDate)); albums.sort((a, b) => a.favoriteDate.compareTo(b.favoriteDate));
switch (_sort) { switch (_sort.type) {
case AlbumSortType.DEFAULT: case SortType.DEFAULT:
return albums; break;
case AlbumSortType.REVERSE: case SortType.ALPHABETIC:
return albums.reversed.toList();
case AlbumSortType.ALPHABETIC:
albums.sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); albums.sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
return albums; break;
case AlbumSortType.ARTIST: case SortType.ARTIST:
albums.sort((a, b) => a.artists[0].name.toLowerCase().compareTo(b.artists[0].name.toLowerCase())); albums.sort((a, b) => a.artists[0].name.toLowerCase().compareTo(b.artists[0].name.toLowerCase()));
return albums; break;
case AlbumSortType.DATE: case SortType.RELEASE_DATE:
albums.sort((a, b) => DateTime.parse(a.releaseDate).compareTo(DateTime.parse(b.releaseDate))); albums.sort((a, b) => DateTime.parse(a.releaseDate).compareTo(DateTime.parse(b.releaseDate)));
return albums; break;
} }
//Reverse
if (_sort.reverse)
return albums.reversed.toList();
return albums; return albums;
} }
@ -550,43 +575,65 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
@override @override
void initState() { void initState() {
_load(); _load();
_sort = cache.albumSort??AlbumSortType.DEFAULT; //Load sorting
int index = Sorting.index(SortSourceTypes.ALBUMS);
if (index != null)
_sort = cache.sorts[index];
super.initState(); super.initState();
} }
Future _reverse() async {
setState(() => _sort.reverse = !_sort.reverse);
//Save sorting in cache
int index = Sorting.index(SortSourceTypes.ALBUMS);
if (index != null) {
cache.sorts[index] = _sort;
} else {
cache.sorts.add(_sort);
}
await cache.save();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: FreezerAppBar( appBar: FreezerAppBar(
'Albums'.i18n, 'Albums'.i18n,
actions: [ actions: [
IconButton(
icon: Icon(_sort.reverse ? FontAwesome5.sort_alpha_up : FontAwesome5.sort_alpha_down),
onPressed: () => _reverse(),
),
PopupMenuButton( PopupMenuButton(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: Icon(Icons.sort, size: 32.0), child: Icon(Icons.sort, size: 32.0),
onSelected: (AlbumSortType s) async { onSelected: (SortType s) async {
setState(() => _sort = s); setState(() => _sort.type = s);
cache.albumSort = s; //Save to cache
int index = Sorting.index(SortSourceTypes.ALBUMS);
if (index == null) {
cache.sorts.add(_sort);
} else {
cache.sorts[index] = _sort;
}
await cache.save(); await cache.save();
}, },
itemBuilder: (context) => <PopupMenuEntry<AlbumSortType>>[ itemBuilder: (context) => <PopupMenuEntry<SortType>>[
PopupMenuItem( PopupMenuItem(
value: AlbumSortType.DEFAULT, value: SortType.DEFAULT,
child: Text('Default'.i18n, style: popupMenuTextStyle()), child: Text('Default'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: AlbumSortType.REVERSE, value: SortType.ALPHABETIC,
child: Text('Reverse'.i18n, style: popupMenuTextStyle()),
),
PopupMenuItem(
value: AlbumSortType.ALPHABETIC,
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: AlbumSortType.ARTIST, value: SortType.ARTIST,
child: Text('Artist'.i18n, style: popupMenuTextStyle()), child: Text('Artist'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: AlbumSortType.DATE, value: SortType.RELEASE_DATE,
child: Text('Release date'.i18n, style: popupMenuTextStyle()), child: Text('Release date'.i18n, style: popupMenuTextStyle()),
), ),
], ],
@ -675,12 +722,6 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
} }
} }
enum ArtistSortType {
DEFAULT,
REVERSE,
POPULARITY,
ALPHABETIC
}
class LibraryArtists extends StatefulWidget { class LibraryArtists extends StatefulWidget {
@override @override
@ -690,7 +731,7 @@ class LibraryArtists extends StatefulWidget {
class _LibraryArtistsState extends State<LibraryArtists> { class _LibraryArtistsState extends State<LibraryArtists> {
List<Artist> _artists; List<Artist> _artists;
ArtistSortType _sort = ArtistSortType.DEFAULT; Sorting _sort = Sorting(sourceType: SortSourceTypes.ARTISTS);
bool _loading = true; bool _loading = true;
bool _error = false; bool _error = false;
ScrollController _scrollController = ScrollController(); ScrollController _scrollController = ScrollController();
@ -698,18 +739,19 @@ class _LibraryArtistsState extends State<LibraryArtists> {
List<Artist> get _sorted { List<Artist> get _sorted {
List<Artist> artists = List.from(_artists); List<Artist> artists = List.from(_artists);
artists.sort((a, b) => a.favoriteDate.compareTo(b.favoriteDate)); artists.sort((a, b) => a.favoriteDate.compareTo(b.favoriteDate));
switch (_sort) { switch (_sort.type) {
case ArtistSortType.DEFAULT: case SortType.DEFAULT:
return artists; break;
case ArtistSortType.REVERSE: case SortType.POPULARITY:
return artists.reversed.toList();
case ArtistSortType.POPULARITY:
artists.sort((a, b) => b.fans - a.fans); artists.sort((a, b) => b.fans - a.fans);
return artists; break;
case ArtistSortType.ALPHABETIC: case SortType.ALPHABETIC:
artists.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); artists.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return artists; break;
} }
//Reverse
if (_sort.reverse)
return artists.reversed.toList();
return artists; return artists;
} }
@ -732,9 +774,26 @@ class _LibraryArtistsState extends State<LibraryArtists> {
}); });
} }
Future _reverse() async {
setState(() => _sort.reverse = !_sort.reverse);
//Save sorting in cache
int index = Sorting.index(SortSourceTypes.ARTISTS);
if (index != null) {
cache.sorts[index] = _sort;
} else {
cache.sorts.add(_sort);
}
await cache.save();
}
@override @override
void initState() { void initState() {
_sort = cache.artistSort; //Restore sort
int index = Sorting.index(SortSourceTypes.ARTISTS);
if (index != null)
_sort = cache.sorts[index];
_load(); _load();
super.initState(); super.initState();
} }
@ -745,29 +804,35 @@ class _LibraryArtistsState extends State<LibraryArtists> {
appBar: FreezerAppBar( appBar: FreezerAppBar(
'Artists'.i18n, 'Artists'.i18n,
actions: [ actions: [
IconButton(
icon: Icon(_sort.reverse ? FontAwesome5.sort_alpha_up : FontAwesome5.sort_alpha_down),
onPressed: () => _reverse(),
),
PopupMenuButton( PopupMenuButton(
child: Icon(Icons.sort, size: 32.0), child: Icon(Icons.sort, size: 32.0),
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
onSelected: (ArtistSortType s) async { onSelected: (SortType s) async {
setState(() => _sort = s); setState(() => _sort.type = s);
cache.artistSort = s; //Save
int index = Sorting.index(SortSourceTypes.ARTISTS);
if (index == null) {
cache.sorts.add(_sort);
} else {
cache.sorts[index] = _sort;
}
await cache.save(); await cache.save();
}, },
itemBuilder: (context) => <PopupMenuEntry<ArtistSortType>>[ itemBuilder: (context) => <PopupMenuEntry<SortType>>[
PopupMenuItem( PopupMenuItem(
value: ArtistSortType.DEFAULT, value: SortType.DEFAULT,
child: Text('Default'.i18n, style: popupMenuTextStyle()), child: Text('Default'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: ArtistSortType.REVERSE, value: SortType.ALPHABETIC,
child: Text('Reverse'.i18n, style: popupMenuTextStyle()),
),
PopupMenuItem(
value: ArtistSortType.ALPHABETIC,
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: ArtistSortType.POPULARITY, value: SortType.POPULARITY,
child: Text('Popularity'.i18n, style: popupMenuTextStyle()), child: Text('Popularity'.i18n, style: popupMenuTextStyle()),
), ),
], ],
@ -819,14 +884,6 @@ class _LibraryArtistsState extends State<LibraryArtists> {
} }
} }
enum PlaylistSortType {
DEFAULT,
REVERSE,
ALPHABETIC,
USER,
TRACK_COUNT
}
class LibraryPlaylists extends StatefulWidget { class LibraryPlaylists extends StatefulWidget {
@override @override
_LibraryPlaylistsState createState() => _LibraryPlaylistsState(); _LibraryPlaylistsState createState() => _LibraryPlaylistsState();
@ -835,27 +892,27 @@ class LibraryPlaylists extends StatefulWidget {
class _LibraryPlaylistsState extends State<LibraryPlaylists> { class _LibraryPlaylistsState extends State<LibraryPlaylists> {
List<Playlist> _playlists; List<Playlist> _playlists;
PlaylistSortType _sort = PlaylistSortType.DEFAULT; Sorting _sort = Sorting(sourceType: SortSourceTypes.PLAYLISTS);
ScrollController _scrollController = ScrollController(); ScrollController _scrollController = ScrollController();
String _filter = ''; String _filter = '';
List<Playlist> get _sorted { List<Playlist> get _sorted {
List<Playlist> playlists = List.from(_playlists.where((p) => p.title.toLowerCase().contains(_filter.toLowerCase()))); List<Playlist> playlists = List.from(_playlists.where((p) => p.title.toLowerCase().contains(_filter.toLowerCase())));
switch (_sort) { switch (_sort.type) {
case PlaylistSortType.DEFAULT: case SortType.DEFAULT:
return playlists; break;
case PlaylistSortType.REVERSE: case SortType.USER:
return playlists.reversed.toList();
case PlaylistSortType.USER:
playlists.sort((a, b) => (a.user.name??deezerAPI.userName).toLowerCase().compareTo((b.user.name??deezerAPI.userName).toLowerCase())); playlists.sort((a, b) => (a.user.name??deezerAPI.userName).toLowerCase().compareTo((b.user.name??deezerAPI.userName).toLowerCase()));
return playlists; break;
case PlaylistSortType.TRACK_COUNT: case SortType.TRACK_COUNT:
playlists.sort((a, b) => b.trackCount - a.trackCount); playlists.sort((a, b) => b.trackCount - a.trackCount);
return playlists; break;
case PlaylistSortType.ALPHABETIC: case SortType.ALPHABETIC:
playlists.sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); playlists.sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
return playlists; break;
} }
if (_sort.reverse)
return playlists.reversed.toList();
return playlists; return playlists;
} }
@ -868,9 +925,25 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
} }
} }
Future _reverse() async {
setState(() => _sort.reverse = !_sort.reverse);
//Save sorting in cache
int index = Sorting.index(SortSourceTypes.PLAYLISTS);
if (index != null) {
cache.sorts[index] = _sort;
} else {
cache.sorts.add(_sort);
}
await cache.save();
}
@override @override
void initState() { void initState() {
_sort = cache.libraryPlaylistSort; //Restore sort
int index = Sorting.index(SortSourceTypes.PLAYLISTS);
if (index != null)
_sort = cache.sorts[index];
_load(); _load();
super.initState(); super.initState();
} }
@ -892,33 +965,39 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
appBar: FreezerAppBar( appBar: FreezerAppBar(
'Playlists'.i18n, 'Playlists'.i18n,
actions: [ actions: [
IconButton(
icon: Icon(_sort.reverse ? FontAwesome5.sort_alpha_up : FontAwesome5.sort_alpha_down),
onPressed: () => _reverse(),
),
PopupMenuButton( PopupMenuButton(
child: Icon(Icons.sort, size: 32.0), child: Icon(Icons.sort, size: 32.0),
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
onSelected: (PlaylistSortType s) async { onSelected: (SortType s) async {
setState(() => _sort = s); setState(() => _sort.type = s);
cache.libraryPlaylistSort = s; //Save to cache
int index = Sorting.index(SortSourceTypes.PLAYLISTS);
if (index == null)
cache.sorts.add(_sort);
else
cache.sorts[index] = _sort;
await cache.save(); await cache.save();
}, },
itemBuilder: (context) => <PopupMenuEntry<PlaylistSortType>>[ itemBuilder: (context) => <PopupMenuEntry<SortType>>[
PopupMenuItem( PopupMenuItem(
value: PlaylistSortType.DEFAULT, value: SortType.DEFAULT,
child: Text('Default'.i18n, style: popupMenuTextStyle()), child: Text('Default'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: PlaylistSortType.REVERSE, value: SortType.USER,
child: Text('Reverse'.i18n, style: popupMenuTextStyle()),
),
PopupMenuItem(
value: PlaylistSortType.USER,
child: Text('User'.i18n, style: popupMenuTextStyle()), child: Text('User'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: PlaylistSortType.TRACK_COUNT, value: SortType.TRACK_COUNT,
child: Text('Track count'.i18n, style: popupMenuTextStyle()), child: Text('Track count'.i18n, style: popupMenuTextStyle()),
), ),
PopupMenuItem( PopupMenuItem(
value: PlaylistSortType.ALPHABETIC, value: SortType.ALPHABETIC,
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()), child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
), ),
], ],

View File

@ -13,6 +13,7 @@ import 'package:freezer/ui/error.dart';
import 'package:freezer/translations.i18n.dart'; import 'package:freezer/translations.i18n.dart';
import 'package:numberpicker/numberpicker.dart'; import 'package:numberpicker/numberpicker.dart';
import 'package:share/share.dart'; import 'package:share/share.dart';
import 'package:url_launcher/url_launcher.dart';
import '../api/definitions.dart'; import '../api/definitions.dart';
import 'cached_image.dart'; import 'cached_image.dart';
@ -501,6 +502,35 @@ class MenuSheet {
}, },
); );
//===================
// SHOW/EPISODE
//===================
defaultShowEpisodeMenu(Show s, ShowEpisode e, {List<Widget> options = const []}) {
show([
shareTile('episode', e.id),
shareShow(s.id),
downloadExternalEpisode(e),
...options
]);
}
Widget shareShow(String id) => ListTile(
title: Text('Share show'.i18n),
leading: Icon(Icons.share),
onTap: () async {
Share.share('https://deezer.com/show/$id');
},
);
//Open direct download link in browser
Widget downloadExternalEpisode(ShowEpisode e) => ListTile(
title: Text('Download externally'.i18n),
leading: Icon(Icons.file_download),
onTap: () async {
launch(e.url);
},
);
//=================== //===================
// OTHER // OTHER

View File

@ -1,10 +1,15 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_screenutil/screenutil.dart'; import 'package:flutter_screenutil/screenutil.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/player.dart'; import 'package:freezer/api/player.dart';
import 'package:freezer/settings.dart'; import 'package:freezer/settings.dart';
import 'package:freezer/translations.i18n.dart'; import 'package:freezer/translations.i18n.dart';
@ -25,6 +30,7 @@ import 'player_bar.dart';
import 'dart:ui'; import 'dart:ui';
import 'dart:async'; import 'dart:async';
class PlayerScreen extends StatefulWidget { class PlayerScreen extends StatefulWidget {
@override @override
_PlayerScreenState createState() => _PlayerScreenState(); _PlayerScreenState createState() => _PlayerScreenState();
@ -36,10 +42,18 @@ class _PlayerScreenState extends State<PlayerScreen> {
StreamSubscription _mediaItemSub; StreamSubscription _mediaItemSub;
//Calculate background color //Calculate background color
Future _calculateColor() async { Future _updateColor() async {
if (!settings.colorGradientBackground) if (!settings.colorGradientBackground)
return; return;
//Run in isolate
PaletteGenerator palette = await PaletteGenerator.fromImageProvider(CachedNetworkImageProvider(AudioService.currentMediaItem.extras['thumb'] ?? AudioService.currentMediaItem.artUri)); PaletteGenerator palette = await PaletteGenerator.fromImageProvider(CachedNetworkImageProvider(AudioService.currentMediaItem.extras['thumb'] ?? AudioService.currentMediaItem.artUri));
//Update notification
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: palette.dominantColor.color.withOpacity(0.5)
));
setState(() => _bgGradient = LinearGradient( setState(() => _bgGradient = LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
@ -53,9 +67,9 @@ class _PlayerScreenState extends State<PlayerScreen> {
@override @override
void initState() { void initState() {
_calculateColor(); Future.delayed(Duration(milliseconds: 1000), _updateColor);
_mediaItemSub = AudioService.currentMediaItemStream.listen((event) { _mediaItemSub = AudioService.currentMediaItemStream.listen((event) {
_calculateColor(); _updateColor();
}); });
super.initState(); super.initState();
} }
@ -67,6 +81,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
//Fix bottom buttons //Fix bottom buttons
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
systemNavigationBarColor: settings.themeData.bottomAppBarColor, systemNavigationBarColor: settings.themeData.bottomAppBarColor,
statusBarColor: Colors.transparent
)); ));
super.dispose(); super.dispose();
} }
@ -214,26 +229,9 @@ class _PlayerScreenHorizontalState extends State<PlayerScreenHorizontal> {
)); ));
}, },
), ),
if (AudioService.currentMediaItem.extras['qualityString'] != null) QualityInfoWidget(),
FlatButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => QualitySettings())
),
child: Text(
AudioService.currentMediaItem.extras['qualityString'] ?? '',
style: TextStyle(fontSize: ScreenUtil().setSp(24)),
),
),
RepeatButton(ScreenUtil().setWidth(32)), RepeatButton(ScreenUtil().setWidth(32)),
IconButton( PlayerMenuButton()
icon: Icon(Icons.more_vert, size: ScreenUtil().setWidth(32)),
onPressed: () {
Track t = Track.fromMediaItem(AudioService.currentMediaItem);
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t, options: [m.sleepTimer()]);
},
)
], ],
), ),
) )
@ -333,32 +331,93 @@ class _PlayerScreenVerticalState extends State<PlayerScreenVertical> {
)); ));
}, },
), ),
if (AudioService.currentMediaItem.extras['qualityString'] != null) QualityInfoWidget(),
FlatButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => QualitySettings())
),
child: Text(
AudioService.currentMediaItem.extras['qualityString'] ?? '',
style: TextStyle(
fontSize: ScreenUtil().setSp(32),
),
),
),
RepeatButton(ScreenUtil().setWidth(46)), RepeatButton(ScreenUtil().setWidth(46)),
IconButton( PlayerMenuButton()
],
),
)
],
);
}
}
class QualityInfoWidget extends StatefulWidget {
@override
_QualityInfoWidgetState createState() => _QualityInfoWidgetState();
}
class _QualityInfoWidgetState extends State<QualityInfoWidget> {
String value = '';
StreamSubscription streamSubscription;
//Load data from native
void _load() async {
if (AudioService.currentMediaItem == null) return;
Map data = await DownloadManager.platform.invokeMethod("getStreamInfo", {"id": AudioService.currentMediaItem.id});
//N/A
if (data == null) {
setState(() => value = '');
//If not show, try again later
if (AudioService.currentMediaItem.extras['show'] == null)
Future.delayed(Duration(milliseconds: 200), _load);
return;
}
//Update
StreamQualityInfo info = StreamQualityInfo.fromJson(data);
setState(() {
value = '${info.format} ${info.bitrate(AudioService.currentMediaItem.duration)}kbps';
});
}
@override
void initState() {
_load();
if (streamSubscription == null)
streamSubscription = AudioService.currentMediaItemStream.listen((event) async {
await _load();
});
super.initState();
}
@override
void dispose() {
if (streamSubscription != null)
streamSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FlatButton(
child: Text(value),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => QualitySettings()));
},
);
}
}
class PlayerMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(Icons.more_vert, size: ScreenUtil().setWidth(46)), icon: Icon(Icons.more_vert, size: ScreenUtil().setWidth(46)),
onPressed: () { onPressed: () {
Track t = Track.fromMediaItem(AudioService.currentMediaItem); Track t = Track.fromMediaItem(AudioService.currentMediaItem);
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
if (AudioService.currentMediaItem.extras['show'] == null)
m.defaultTrackMenu(t, options: [m.sleepTimer()]); m.defaultTrackMenu(t, options: [m.sleepTimer()]);
else
m.defaultShowEpisodeMenu(
Show.fromJson(jsonDecode(AudioService.currentMediaItem.extras['show'])),
ShowEpisode.fromMediaItem(AudioService.currentMediaItem),
options: [m.sleepTimer()]
);
}, },
)
],
),
)
],
); );
} }
} }

View File

@ -1,11 +1,15 @@
import 'package:connectivity/connectivity.dart'; import 'package:connectivity/connectivity.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:fluttericon/typicons_icons.dart';
import 'package:flutter/src/services/keyboard_key.dart'; import 'package:flutter/src/services/keyboard_key.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/download.dart'; import 'package:freezer/api/download.dart';
import 'package:freezer/api/player.dart'; import 'package:freezer/api/player.dart';
import 'package:freezer/ui/details_screens.dart'; import 'package:freezer/ui/details_screens.dart';
import 'package:freezer/ui/elements.dart'; import 'package:freezer/ui/elements.dart';
import 'package:freezer/ui/home_screen.dart';
import 'package:freezer/ui/menu.dart'; import 'package:freezer/ui/menu.dart';
import 'package:freezer/translations.i18n.dart'; import 'package:freezer/translations.i18n.dart';
@ -53,6 +57,8 @@ class _SearchScreenState extends State<SearchScreen> {
TextEditingController _controller = new TextEditingController(); TextEditingController _controller = new TextEditingController();
List _suggestions = []; List _suggestions = [];
bool _cancel = false; bool _cancel = false;
bool _showCards = true;
FocusNode _focus = FocusNode();
void _submit(BuildContext context, {String query}) async { void _submit(BuildContext context, {String query}) async {
if (query != null) _query = query; if (query != null) _query = query;
@ -152,6 +158,9 @@ class _SearchScreenState extends State<SearchScreen> {
setState(() => _query = s); setState(() => _query = s);
_loadSuggestions(); _loadSuggestions();
}, },
onTap: () {
setState(() => _showCards = false);
},
focusNode: textFielFocusNode, focusNode: textFielFocusNode,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Search or paste URL'.i18n, labelText: 'Search or paste URL'.i18n,
@ -212,8 +221,87 @@ class _SearchScreenState extends State<SearchScreen> {
LinearProgressIndicator(), LinearProgressIndicator(),
FreezerDivider(), FreezerDivider(),
//"Browse" Cards
if (_showCards)
...[
Padding(
padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
'Quick access',
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SearchBrowseCard(
color: Color(0xff11b192),
text: 'Flow'.i18n,
icon: Icon(Typicons.waves),
onTap: () async {
await playerHelper.playFromSmartTrackList(SmartTrackList(id: 'flow'));
},
),
SearchBrowseCard(
color: Color(0xff7c42bb),
text: 'Shows'.i18n,
icon: Icon(FontAwesome5.podcast),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar('Shows'.i18n),
body: SingleChildScrollView(
child: HomePageScreen(
channel: DeezerChannel(target: 'shows')
)
),
),
)),
)
],
),
Container(height: 4.0),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SearchBrowseCard(
color: Color(0xffff555d),
icon: Icon(FontAwesome5.chart_line),
text: 'Charts'.i18n,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar('Charts'.i18n),
body: SingleChildScrollView(
child: HomePageScreen(
channel: DeezerChannel(target: 'channels/charts')
)
),
),
)),
),
SearchBrowseCard(
color: Color(0xff2c4ea7),
text: 'Browse'.i18n,
icon: Image.asset('assets/browse_icon.png', width: 26.0),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar('Browse'.i18n),
body: SingleChildScrollView(
child: HomePageScreen(
channel: DeezerChannel(target: 'channels/explore')
)
),
),
)),
)
],
)
],
//History //History
if (cache.searchHistory != null && cache.searchHistory.length > 0 && (_query??'').length < 2) if (!_showCards && cache.searchHistory != null && cache.searchHistory.length > 0 && (_query??'').length < 2)
...List.generate(cache.searchHistory.length > 10 ? 10 : cache.searchHistory.length, (int i) { ...List.generate(cache.searchHistory.length > 10 ? 10 : cache.searchHistory.length, (int i) {
dynamic data = cache.searchHistory[i].data; dynamic data = cache.searchHistory[i].data;
switch (cache.searchHistory[i].type) { switch (cache.searchHistory[i].type) {
@ -308,6 +396,50 @@ class _SearchScreenState extends State<SearchScreen> {
} }
} }
class SearchBrowseCard extends StatelessWidget {
final Color color;
final Widget icon;
final Function onTap;
final String text;
SearchBrowseCard({@required this.color, @required this.onTap, @required this.text, this.icon});
@override
Widget build(BuildContext context) {
return Card(
color: color,
child: InkWell(
onTap: this.onTap,
child: Container(
width: MediaQuery.of(context).size.width / 2 - 32,
height: 75,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
icon,
if (icon != null)
Container(width: 8.0),
Text(
text,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
color: (color.computeLuminance() > 0.5) ? Colors.black:Colors.white
),
),
],
)
),
),
)
);
}
}
class SearchResultsScreen extends StatelessWidget { class SearchResultsScreen extends StatelessWidget {

View File

@ -46,6 +46,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
'name': 'Filipino', 'name': 'Filipino',
'isoCode': 'fil' 'isoCode': 'fil'
}); });
defaultLanguagesList.add({
'name': 'Furry',
'isoCode': 'uwu'
});
List<Map<String, String>> _l = supportedLocales.map<Map<String, String>>((l) { List<Map<String, String>> _l = supportedLocales.map<Map<String, String>>((l) {
Map _lang = defaultLanguagesList.firstWhere((lang) => lang['isoCode'] == l.languageCode); Map _lang = defaultLanguagesList.firstWhere((lang) => lang['isoCode'] == l.languageCode);
return { return {
@ -445,7 +449,7 @@ class _QualityPickerState extends State<QualityPicker> {
), ),
if (widget.field == 'download') if (widget.field == 'download')
ListTile( ListTile(
title: Text('Ask before downloading'), title: Text('Ask before downloading'.i18n),
leading: Radio( leading: Radio(
groupValue: _quality, groupValue: _quality,
value: AudioQuality.ASK, value: AudioQuality.ASK,
@ -947,8 +951,8 @@ class _GeneralSettingsState extends State<GeneralSettings> {
), ),
FlatButton( FlatButton(
child: Text('(ARL ONLY) Continue'.i18n), child: Text('(ARL ONLY) Continue'.i18n),
onPressed: () { onPressed: () async {
logOut(); await logOut();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
@ -1073,6 +1077,11 @@ class _DirectoryPickerState extends State<DirectoryPicker> {
super.initState(); super.initState();
} }
Future _resetPath() async {
StorageInfo si = (await PathProviderEx.getStorageInfo())[0];
setState(() => _path = si.appFilesDir);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -1147,7 +1156,13 @@ class _DirectoryPickerState extends State<DirectoryPicker> {
builder: (BuildContext context, AsyncSnapshot snapshot) { builder: (BuildContext context, AsyncSnapshot snapshot) {
//On error go to last good path //On error go to last good path
if (snapshot.hasError) Future.delayed(Duration(milliseconds: 50), () => setState(() => _path = _previous)); if (snapshot.hasError) Future.delayed(Duration(milliseconds: 50), () {
if (_previous == null) {
_resetPath();
return;
}
setState(() => _path = _previous);
});
if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),); if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),);
List<FileSystemEntity> data = snapshot.data; List<FileSystemEntity> data = snapshot.data;
@ -1266,12 +1281,20 @@ class _CreditsScreenState extends State<CreditsScreen> {
launch('https://t.me/freezerandroid'); launch('https://t.me/freezerandroid');
}, },
), ),
ListTile(
title: Text('Discord'.i18n),
subtitle: Text('Official Discord server'.i18n),
leading: Icon(FontAwesome5.discord, color: Color(0xff7289da), size: 36.0),
onTap: () {
launch('https://discord.gg/7ap654Tp3z');
},
),
ListTile( ListTile(
title: Text('Repository'.i18n), title: Text('Repository'.i18n),
subtitle: Text('Source code, report issues there.'.i18n), subtitle: Text('Source code, report issues there.'.i18n),
leading: Icon(Icons.code, color: Colors.green, size: 36.0), leading: Icon(Icons.code, color: Colors.green, size: 36.0),
onTap: () { onTap: () {
launch('https://notabug.org/exttex/freezer'); launch('https://git.rip/freezer/');
}, },
), ),
FreezerDivider(), FreezerDivider(),

View File

@ -322,7 +322,8 @@ class SmartTrackListTile extends StatelessWidget {
blurRadius: 2, blurRadius: 2,
color: Colors.black color: Colors.black
) )
] ],
color: Colors.white
), ),
), ),
), ),
@ -450,3 +451,104 @@ class ChannelTile extends StatelessWidget {
); );
} }
} }
class ShowCard extends StatelessWidget {
final Show show;
final Function onTap;
final Function onHold;
ShowCard(this.show, {this.onTap, this.onHold});
@override
Widget build(BuildContext context) {
return Container(
child: InkWell(
onTap: onTap,
onLongPress: onHold,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.all(8.0),
child: CachedImage(
url: show.art.thumb,
width: 128.0,
height: 128.0,
rounded: true,
),
),
Container(
width: 144.0,
child: Text(
show.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.0
),
),
),
],
),
),
);
}
}
class ShowEpisodeTile extends StatelessWidget {
final ShowEpisode episode;
final Function onTap;
final Function onHold;
final Widget trailing;
ShowEpisodeTile(this.episode, {this.onTap, this.onHold, this.trailing});
@override
Widget build(BuildContext context) {
return InkWell(
onLongPress: onHold,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(episode.title, maxLines: 2),
trailing: trailing,
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
episode.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).textTheme.subtitle1.color.withOpacity(0.9)
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16, 8.0, 0, 0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(
'${episode.publishedDate} | ${episode.durationString}',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.subtitle1.color.withOpacity(0.6)
),
),
],
),
),
Divider(),
],
),
);
}
}

View File

@ -15,6 +15,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'dart:convert'; import 'dart:convert';
import 'package:version/version.dart';
class UpdaterScreen extends StatefulWidget { class UpdaterScreen extends StatefulWidget {
@override @override
@ -109,12 +111,13 @@ class _UpdaterScreenState extends State<UpdaterScreen> {
), ),
), ),
if (!_error && !_loading && _versions.latest == _current) if (!_error && !_loading && Version.parse(_versions.latest) <= Version.parse(_current))
Center( Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
child: Text( child: Text(
'You are running latest version!'.i18n, 'You are running latest version!'.i18n,
textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 26.0 fontSize: 26.0
) )
@ -122,17 +125,20 @@ class _UpdaterScreenState extends State<UpdaterScreen> {
) )
), ),
if (!_error && !_loading && _versions.latest != _current) if (!_error && !_loading && Version.parse(_versions.latest) > Version.parse(_current))
Column( Column(
children: [ children: [
Text( Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'New update available!'.i18n + ' ' + _versions.latest, 'New update available!'.i18n + ' ' + _versions.latest,
textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 20.0, fontSize: 20.0,
fontWeight: FontWeight.bold fontWeight: FontWeight.bold
), ),
), ),
Container(height: 8.0), ),
Text( Text(
'Current version: ' + _current, 'Current version: ' + _current,
style: TextStyle( style: TextStyle(
@ -204,6 +210,7 @@ class FreezerVersions {
//Fetch from website API //Fetch from website API
static Future<FreezerVersions> fetch() async { static Future<FreezerVersions> fetch() async {
http.Response response = await http.get('https://freezer.life/api/versions'); http.Response response = await http.get('https://freezer.life/api/versions');
// http.Response response = await http.get('https://cum.freezerapp.workers.dev/api/versions');
return FreezerVersions.fromJson(jsonDecode(response.body)); return FreezerVersions.fromJson(jsonDecode(response.body));
} }
@ -218,7 +225,7 @@ class FreezerVersions {
//Load current version //Load current version
PackageInfo info = await PackageInfo.fromPlatform(); PackageInfo info = await PackageInfo.fromPlatform();
if (info.version == versions.latest) return; if (Version.parse(versions.latest) <= Version.parse(info.version)) return;
//Get architecture //Get architecture
String _arch = await DownloadManager.platform.invokeMethod("arch"); String _arch = await DownloadManager.platform.invokeMethod("arch");

View File

@ -56,14 +56,14 @@ packages:
name: build name: build
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.5.0" version: "1.5.1"
build_config: build_config:
dependency: transitive dependency: transitive
description: description:
name: build_config name: build_config
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.2" version: "0.4.3"
build_daemon: build_daemon:
dependency: transitive dependency: transitive
description: description:
@ -84,14 +84,14 @@ packages:
name: build_runner name: build_runner
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.10.4" version: "1.10.6"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
name: build_runner_core name: build_runner_core
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.3" version: "6.1.1"
built_collection: built_collection:
dependency: transitive dependency: transitive
description: description:
@ -245,7 +245,7 @@ packages:
name: dart_style name: dart_style
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.9" version: "1.3.10"
dio: dio:
dependency: transitive dependency: transitive
description: description:
@ -496,7 +496,7 @@ packages:
name: json_annotation name: json_annotation
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.0" version: "3.1.1"
json_serializable: json_serializable:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -507,10 +507,24 @@ packages:
just_audio: just_audio:
dependency: "direct main" dependency: "direct main"
description: description:
path: just_audio name: just_audio
relative: true url: "https://pub.dartlang.org"
source: path source: hosted
version: "0.4.4" version: "0.5.6"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.1"
language_pickers: language_pickers:
dependency: "direct main" dependency: "direct main"
description: description:
@ -580,7 +594,7 @@ packages:
name: numberpicker name: numberpicker
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.1" version: "1.3.0"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:
@ -692,7 +706,7 @@ packages:
name: photo_view name: photo_view
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.2" version: "0.10.3"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -964,6 +978,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0-nullsafety.3" version: "2.1.0-nullsafety.3"
version:
dependency: "direct main"
description:
name: version
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.6.3+1 version: 0.6.5+1
environment: environment:
sdk: ">=2.8.0 <3.0.0" sdk: ">=2.8.0 <3.0.0"
@ -73,12 +73,13 @@ dependencies:
draggable_scrollbar: ^0.0.4 draggable_scrollbar: ^0.0.4
scrobblenaut: ^2.0.4 scrobblenaut: ^2.0.4
open_file: ^3.0.3 open_file: ^3.0.3
version: ^1.2.0
audio_session: ^0.0.9 audio_session: ^0.0.9
audio_service: audio_service:
path: ./audio_service path: ./audio_service
just_audio: just_audio: ^0.5.6
path: ./just_audio # path: ./just_audio
# cupertino_icons: ^0.1.3 # cupertino_icons: ^0.1.3
@ -110,6 +111,7 @@ flutter:
- assets/cover_thumb.jpg - assets/cover_thumb.jpg
- assets/icon.png - assets/icon.png
- assets/favorites_thumb.jpg - assets/favorites_thumb.jpg
- assets/browse_icon.png
fonts: fonts:
# - family: Montserrat # - family: Montserrat